Skip to content

feat(iam,admission): email verification gate + idempotent User admission - #756

Open
yahyafakhroji wants to merge 6 commits into
mainfrom
feat/passkey-phase-b
Open

feat(iam,admission): email verification gate + idempotent User admission#756
yahyafakhroji wants to merge 6 commits into
mainfrom
feat/passkey-phase-b

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What's the current problem?

Nothing on the API side stops an account that never confirmed its email from creating things. The portal can redirect someone to "check your inbox", but that's a UI redirect — anyone holding a token can call the API directly.

Separately, duplicate User creates return the wrong error. The admission webhook creates three side-effect resources with no AlreadyExists tolerance, and Kubernetes runs validating admission before the etcd write — so a retried or concurrent signup fails admission on the User itself. zitadel-provider's EnsureUser tests IsAlreadyExists, a predicate an admission denial never satisfies, so benign retries surface as 500s.

How are we solving it?

  • An admission plugin reading iam.miloapis.com/emailVerified off the authenticated identity, stamped by zitadel-provider#127 beside the existing registrationApproval key.
  • Idempotent User admission, so a duplicate create returns AlreadyExists. The UserPreference site re-reads on conflict rather than merely tolerating it: Create leaves UID empty, and the third PolicyBinding would stamp that empty UID into its ResourceRef.

Nothing enforces on merge. EmailVerifiedGate defaults off, which means observe, not disabled — see below.

Why are we solving it?

Part of the passkey program — enhancements#738, tracked for this phase in auth-ui#111. An unverified account shouldn't be able to act on the platform.

Why a plugin rather than a policy

A ValidatingAdmissionPolicy expressing the same rule was carried in parallel (datum#268) while this was open. Two things it cannot do:

  • Emit EmailNotVerified in details[].causes. A policy's reason is limited to the built-in enum, so cloud-portal would match message text — where it currently matches details[].code for suspension.
  • Reach project control planes. A policy is deployed to the control planes the GitOps repo targets; config/crd/bootstrap.go seeds CRDs, not policies.

Three things worth knowing

This is 60 lines, not the 559 it started as. The first version read User.status.emailVerified, which cost a per-write API call, an informer cache of ServiceAccount UIDs to tell machines from humans, a readiness check, and a CI timeout when the reflector listed a CRD the bootstrap hook hadn't created. Moving the signal onto the identity removed all of it: a machine token carries no email claim, which is the same discriminator EffectiveUsername() already uses.

EmailVerifiedGate selects deny-vs-observe, not on-vs-off. The plugin always evaluates and always counts; disabled, it admits anyway. Read milo_email_verification_denials_total{enforced="false"} before enabling — that's the population that starts being denied, and it's what makes the rollout possible without a backfill Job. The label mirrors a policy's enforcement_action so the numbers stay comparable if both ever run.

Absence admits, as a contract. Machine identities carry no key and pass. zitadel-provider guarantees the converse and tests it: a human always carries the key, "true" or "false", never omitted. If that breaks, this fails open for humans — the two sides change together.

Verification

go build, go vet, gofmt clean; 30 test packages pass. Plugin cases cover: verified admits, unverified denies on create and update, absent key admits, empty value slice admits, unexpected value denies, access review exempt, system:masters exempt, mid-deletion update exempt, create-with-nil-oldObject still denies, the cause type is present, Delete/Connect unregistered, observe mode counts without denying, and the gate defaults off.

@JoseSzycho

Copy link
Copy Markdown
Contributor

same as here milo-os/zitadel-provider#127 (comment)

@yahyafakhroji

Copy link
Copy Markdown
Contributor Author

same as here milo-os/zitadel-provider#127 (comment)

@JoseSzycho sorry for that,, i just updated it

@JoseSzycho JoseSzycho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yahyafakhroji Did you consider using a ValidatingAdmissionPolicy`instead of writing a plugin and maintaining a cache?

Here's an example of one we're already using:
https://github.com/datum-cloud/datum/blob/main/config/services/iam.miloapis.com/validation/approved-user-policy.yaml

This would make the policy much easier to maintain.

Here's the code in the zitadel-provider repo that populates the information used by this ValidatingAdmissionPolicy:

https://github.com/milo-os/zitadel-provider/blob/171a2e7c017fb5dc04d042604b14059117af42d4/internal/webhook/response.go#L21-L41


Apart from that, I noticed that this PR also adds an Email TTL for automatically deleting emails. Please move this to a separate PR, since it's unrelated to the rest of the changes in this PR.

This is something we need, and we've already created an issue for it:
milo-os/resend-provider#33

Could you also move the controller to the resend-provider repo?

In the medium term, this controller logic should live in its own repository, where we'll keep notification logic that doesn't implement a third-party service. In the meantime, though, it would be fine to have the controller in resend-provider.

@yahyafakhroji

yahyafakhroji commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Note

Mostly resolved — kept for history. Struck-through parts are done or superseded; one item is still live at the bottom.

Thanks @JoseSzycho — agreed on both.

Email TTL — splitting it out. The API field has to stay in milo (the CRD lives here); the controller goes to resend-provider against milo-os/resend-provider#33. One check: #33 mentions cleanup at a size threshold, but I built TTL-after-delivery plus a max-age ceiling, with no size trigger. Enough for now?

Superseded, and I had this wrong. The field left milo as well. Retention isn't among enhancements#738's goals and #33 is storage-motivated, so both the controller and spec.ttlSecondsAfterDelivery were descoped rather than split. The size-threshold question moves to #33's own track.

VAP — you're right. Most of the plugin's weight is a ServiceAccount cache, and it only exists because the signal lives in a CR that's created asynchronously — there's a window where a human has a valid token but no User yet. Putting emailVerified in userInfo.extra like registrationApproval closes that window, and the cache goes with it. Cheaper on the provider side too.

Done. datum#268 (policy) + zp#127 (producer).

One question before I rewrite it: the plugin runs in every milo apiserver, including project control planes. A VAP is deployed through datum/config/services/... and milo's bootstrap seeds CRDs only — so project control planes look uncovered. deny-unapproved-user has the same gap today. Accepted limitation, or is there a distribution path I'm missing?

Answered — plugin dropped, the policy is the only enforcement. Worth noting milo owns no ProjectControlPlane controller (only the CRD type), so how those apiservers get configured was never milo's to decide anyway.


Still live: the plugin denied with a custom EmailNotVerified cause in details[].causes, which a policy can't emit — reason is limited to the built-in enum. cloud-portal matches suspension 403s on details[].code, so the verification equivalent will have to match message text. Tracking on cp#1430, not here.

ValidateCreate creates three resources with no AlreadyExists tolerance.
Kubernetes runs validating admission before the etcd write, so a retried or
concurrent signup fails admission on the User itself - and zitadel-provider's
EnsureUser tests IsAlreadyExists, a predicate an admission denial never
satisfies, turning a benign retry into a 500. Both callers depend on this: the
actions handler and user_sweep.go, whose design is calling EnsureUser
repeatedly.

The UserPreference site needs a re-read, not just tolerance: Create leaves UID
empty on conflict and the third PolicyBinding stamps that UID into its
ResourceRef, which would emit an authorization record pointing at "".

Existing PolicyBindings are left alone rather than 'repaired'. The apiserver
stamps a fresh UID on every create attempt, so a repair keyed on it would
delete a correct binding and recreate it pinned to a UID that never persists -
and spec.resourceSelector is immutable, so that damage is permanent.
@yahyafakhroji yahyafakhroji changed the title feat(iam,notification): Phase B milo lane — email verification gate, admission backstop, Email TTL feat(iam,admission): Phase B milo lane — email verification gate, idempotent User admission Aug 17, 2026
Comment thread internal/apiserver/admission/plugin/emailverification/admission.go
Enforcement itself lives in datum-cloud/datum#268 as a
ValidatingAdmissionPolicy. The test lives here because this is where a live
milo apiserver exists; datum has no test infrastructure to exercise CEL
against.

Covers the failure that would hurt most. failurePolicy is Fail, so an
expression that compiles but errors at evaluation denies every write in the
cluster. Creating the policy is the compile check - the apiserver type-checks
expressions at admission-registration time - and the write that follows proves
it does not error when evaluated.

It also pins the inertness the rollout depends on: an identity carrying no
emailVerified key must be admitted, because the policy ships before
zitadel-provider starts stamping one.

The binding here uses [Deny], not the [Warn, Audit] that ships. Under Warn a
completely broken expression would not fail the write and this test would pass
without proving anything.

The policy YAML is copied rather than referenced, since it lives in another
repo. If the two drift, this stops testing the thing that deploys.
@yahyafakhroji yahyafakhroji changed the title feat(iam,admission): Phase B milo lane — email verification gate, idempotent User admission fix(iam,test): idempotent User admission + email-verification policy coverage Aug 17, 2026
@yahyafakhroji

yahyafakhroji commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@JoseSzycho Done — plugin removed in 4fb042e, so enforcement lives only in datum#268.

Keeps the facts a maintainer needs — why AlreadyExists is tolerated, why an
existing binding must not be repaired against a UID that never persists, and
why the UserPreference is re-read — and drops the narration around them.
Keeps the copied expression byte-identical to the one in
datum-cloud/datum#268. zitadel-provider never sets Groups on its TokenReview
response, so an identity carrying the emailVerified key can never also be in
system:masters and the clause could not be reached.
test-environment-validation failed with

  spec.validations[0].expression: Internal error: SyntaxError:
  Unexpected token at the end of the expression: TOKRparen

which is not a CEL error at all - TOKRparen is a go-jmespath token. Chainsaw
evaluates any manifest value that opens with "(" and closes with ")" as a
JMESPath expression, so it tried to parse the policy expression instead of
passing it through, and the apiserver never saw valid CEL.

The expression was always correct: it compiles under cel-go and passes
ValidateValidatingAdmissionPolicy unchanged. Dropping the redundant outer
parentheses on the leading clauses makes the string start with "!", which is
enough for chainsaw to treat it as a literal. Semantics are identical - unary
! and == both bind tighter than || - and the same edit is applied to the
policy in datum-cloud/datum#268 so the two copies stay byte-identical.

A comment at the expression records why the first clause must not be
re-parenthesised, since the failure mode is a syntax error pointing at CEL
that is not wrong.

Worth noting the test earned its place here: it exists to catch an expression
that reaches the apiserver broken, and the first thing it caught was an
expression that never reached the apiserver at all.
…fault

Enforcement comes back to milo. A ValidatingAdmissionPolicy cannot do two
things this can: emit EmailNotVerified in details[].causes - a policy's reason
is limited to the built-in enum, so cloud-portal would match message text
instead of a stable code - and reach project control planes, which the GitOps
repo does not deploy policies into.

This is the 60-line version reading iam.miloapis.com/emailVerified off the
authenticated identity, not the 559-line one that read User.status. No client,
no informer cache, no readiness check. The cache existed to answer "is this UID
a machine?", which zitadel-provider now answers at the authenticator: a machine
token carries no email claim, the same discriminator EffectiveUsername already
uses.

EmailVerifiedGate returns, meaning something different. It selects
deny-vs-observe, not on-vs-off: the plugin always evaluates and always counts,
and disabled it admits anyway. That is affordable only because the verdict is a
map lookup on the request itself - the CR-reading version needed a gate to
avoid paying for its cache while idle, which is the opposite reason for the
same switch. Recorded at the gate so it does not read as going in circles.

milo_email_verification_denials_total{enforced} is what makes the rollout
possible without a backfill Job: deploy, read enforced="false", then flip.
The label mirrors a policy's enforcement_action so the numbers stay comparable
if both ever run.

Drops test/admission/email-verification-policy. It existed to prove the CEL
compiles and does not error at evaluation; with no CEL it has no subject, and
its deny path was never reachable anyway - static token files carry no extras.
@yahyafakhroji yahyafakhroji changed the title fix(iam,test): idempotent User admission + email-verification policy coverage feat(iam,admission): email verification gate + idempotent User admission Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants