diff --git a/app/modules/auth/providers/fake/fake-provider.ts b/app/modules/auth/providers/fake/fake-provider.ts index 6f834f04d..01e9677bf 100644 --- a/app/modules/auth/providers/fake/fake-provider.ts +++ b/app/modules/auth/providers/fake/fake-provider.ts @@ -291,8 +291,13 @@ export class FakeAuthProvider implements AuthProvider { // ─── users ──────────────────────────────────────────────────────────────────── - async findUser(identifier: string, _orgId?: string): Promise { - const u = this.users.find((u) => u.loginName === identifier) ?? null; + async findUser(identifier: string, orgId?: string): Promise { + // Honour the org filter like the Zitadel adapter does, but only when BOTH sides carry an org: + // seeds without orgId keep matching regardless of the filter (pre-existing specs rely on it). + const u = + this.users.find( + (u) => u.loginName === identifier && (!orgId || !u.orgId || u.orgId === orgId) + ) ?? null; if (!u) return null; const skippedAt = this.mfaSkippedAt.get(u.id) ?? null; return skippedAt !== null ? { ...u, mfaInitSkippedAt: skippedAt } : u; diff --git a/app/resources/sso/idp-auto-create-allowlist.ts b/app/resources/sso/idp-auto-create-allowlist.ts new file mode 100644 index 000000000..d4aae771d --- /dev/null +++ b/app/resources/sso/idp-auto-create-allowlist.ts @@ -0,0 +1,80 @@ +// app/resources/sso/idp-auto-create-allowlist.ts +/** + * TEMPORARY — staging dual-org interim. Delete this module, its two env flags + * (`IDP_AUTO_CREATE_EMAIL_DOMAINS`, `IDP_AUTO_CREATE_ALIAS_TAG`), the single call site in + * `sso-callback.ts`, and ADR 007 once staging runs one Zitadel org for humans like production + * (design: docs/superpowers/specs/2026-09-21-staging-single-org-migration-design.md). + * + * WHY. Staging pins the staff portal to a Zitadel org whose login policy disallows + * registration, so a new staff member's first Google sign-in dead-ends on `creation-disabled` + * and an admin ends up creating a password user by hand. This lets an IdP-VERIFIED email from an + * allow-listed domain auto-create a user in that org anyway. Password self-signup stays off and + * the sign-up link stays hidden, because both key on the org's `allowRegister`, which is untouched. + * + * THE ALIAS. Zitadel usernames are unique instance-wide and auth-ui uses the email as the + * username. When the same email already owns a user in ANOTHER org, the user is created as + * `local+@domain` instead — the convention admins applied by hand until now. Google + * Workspace delivers plus-addressed mail to the same mailbox, so the IdP's verification of the + * base address is carried over to the alias. + * + * ADR BENDS, both deliberate and confined to this path: + * - ADR 005: the ownership lookup here is instance-wide even though the ceremony carries an + * explicit org, because the question is precisely "does another org own this email". + * - ADR 004: the flags gate WHETHER the door exists; the guards behind it (verified email, + * passwordless account for auto-link) are unchanged and still enforced by the decision. + * + * OFF STATE. With `IDP_AUTO_CREATE_EMAIL_DOMAINS` or `IDP_AUTO_CREATE_ORGS` unset (the default; + * production sets neither) none of this runs: the call site requires the target org to be listed + * in `IDP_AUTO_CREATE_ORGS` before consulting `isAllowlistedIdpEmail`. + * + * KNOWN FALLBACK. The Zitadel adapter's `findUser` returns null when an identifier matches MORE + * than one user (ADR 005's fail-closed rule). Here that reads as "no owner", so the plain email is + * attempted and Zitadel rejects it with ALREADY_EXISTS → `registration-conflict`. Fails closed; + * the alias is simply not offered in that (rare) case. + */ +import type { AuthProvider } from '@/modules/auth/auth-provider'; + +/** True when the IdP asserted a VERIFIED email whose domain is on the allow-list. */ +export function isAllowlistedIdpEmail( + draft: { email?: string; emailVerified?: boolean } | null | undefined, + domains: readonly string[] +): boolean { + if (domains.length === 0 || !draft?.email || !draft.emailVerified) return false; + const at = draft.email.lastIndexOf('@'); + if (at <= 0) return false; + return domains.includes(draft.email.slice(at + 1).toLowerCase()); +} + +/** `local@domain` → `local+@domain`. */ +export function aliasEmail(email: string, tag: string): string { + const at = email.lastIndexOf('@'); + return `${email.slice(0, at)}+${tag}${email.slice(at)}`; +} + +export type AllowlistedRegistration = + /** A user in the TARGET org already owns the email (or its alias): take the existing-account path. */ + | { kind: 'existing'; userId: string } + /** Register a new user in the target org under `email` (`aliased` when it is the alias form). */ + | { kind: 'create'; email: string; aliased: boolean }; + +/** + * Decide how an allow-listed identity registers into `targetOrg`. An owner in another org means + * the plain email is taken instance-wide, so the alias is used unless the alias itself already + * exists in the target org. An owner whose org is unknown is treated as the target org's, which + * fails closed into the existing-account rules rather than minting an alias. + */ +export async function resolveAllowlistedRegistration( + provider: AuthProvider, + input: { email: string; targetOrg: string | undefined; aliasTag: string } +): Promise { + const { email, targetOrg, aliasTag } = input; + const owner = await provider.findUser(email, undefined); + if (!owner) return { kind: 'create', email, aliased: false }; + if (owner.orgId === undefined || owner.orgId === targetOrg) { + return { kind: 'existing', userId: owner.id }; + } + const alias = aliasEmail(email, aliasTag); + const aliasOwner = await provider.findUser(alias, targetOrg); + if (aliasOwner) return { kind: 'existing', userId: aliasOwner.id }; + return { kind: 'create', email: alias, aliased: true }; +} diff --git a/app/resources/sso/sso-callback.ts b/app/resources/sso/sso-callback.ts index d97f89615..f0d01bfa7 100644 --- a/app/resources/sso/sso-callback.ts +++ b/app/resources/sso/sso-callback.ts @@ -22,6 +22,10 @@ import { resolveOrg } from '@/resources/shared/resolve-org'; import { registerAndLinkIdp } from '@/resources/signup'; import { MAXMIND_TRACKING_TOKEN_METADATA_KEY } from '@/resources/signup/signup.service'; import { deriveIdpProfileName } from '@/resources/sso/derive-idp-name'; +import { + isAllowlistedIdpEmail, + resolveAllowlistedRegistration, +} from '@/resources/sso/idp-auto-create-allowlist'; import { decideIdpCallback } from '@/resources/sso/idp-callback'; import { POLICY_ORG_PURPOSE } from '@/resources/sso/idp-return-urls'; import { signInWithIdpIntent, requestScopedProviderReads } from '@/resources/sso/idp-session'; @@ -151,6 +155,10 @@ export async function processIdpCallback( let intent: IdpIntentResult; let entries: Awaited>; let decision: ReturnType; + // TEMPORARY (ADR 007) allow-list state, read again by the auto-create branch below. + let allowlisted: boolean | undefined; + let registerEmail: string | undefined; + let aliased = false; // Resolve the effective org for this callback ceremony — policy-org-first (the org the START // side decided the intent under, see idp-return-urls.ts), then the URL `?organization=`, then // the ZITADEL_DEFAULT_ORG_ID env pin, then the provider's instance Default Organization. @@ -196,18 +204,42 @@ export async function processIdpCallback( const [sessionUserId, settings] = await Promise.all([sessionUserIdP, settingsP]); + // TEMPORARY (staging dual-org interim, ADR 007 — see idp-auto-create-allowlist.ts): an + // IdP-verified email from an allow-listed domain may register into an org whose policy + // disallows registration. Off (production) whenever IDP_AUTO_CREATE_EMAIL_DOMAINS is unset. + allowlisted = + link !== 'true' && + !intent.userId && + !settings.allowRegister && + // Pinned to the listed org(s): callbackOrg can fall back to the raw ?organization= param. + callbackOrg !== undefined && + env.IDP_AUTO_CREATE_ORGS.includes(callbackOrg) && + isAllowlistedIdpEmail(intent.draft, env.IDP_AUTO_CREATE_EMAIL_DOMAINS); + const creationAllowed = settings.allowRegister || allowlisted; + // Resolve a same-email account ONLY on the register path (not linked, not a link ceremony, // creation allowed, draft present) — keeps the lookup off the sign-in path. let existingAccount: { userId: string; hasPassword: boolean } | null = null; - if (link !== 'true' && !intent.userId && settings.allowRegister && intent.draft?.email) { - const existing = await provider.findUser(intent.draft.email, organization); - if (existing) { + if (link !== 'true' && !intent.userId && creationAllowed && intent.draft?.email) { + let existingId: string | undefined; + if (allowlisted) { + const plan = await resolveAllowlistedRegistration(provider, { + email: intent.draft.email, + targetOrg: callbackOrg, + aliasTag: env.IDP_AUTO_CREATE_ALIAS_TAG, + }); + if (plan.kind === 'existing') existingId = plan.userId; + else ({ email: registerEmail, aliased } = plan); + } else { + existingId = (await provider.findUser(intent.draft.email, organization))?.id; + } + if (existingId) { // hasPassword only changes the decision when auto-link is enabled; skip the extra RPC // when ALLOW_IDP_AUTO_LINK is off (existence alone yields the account-exists hard error). const hasPassword = allowAutoLink - ? (await provider.listAuthMethods(existing.id)).includes('password') + ? (await provider.listAuthMethods(existingId)).includes('password') : false; - existingAccount = { userId: existing.id, hasPassword }; + existingAccount = { userId: existingId, hasPassword }; } } @@ -230,7 +262,7 @@ export async function processIdpCallback( intent, link: link === 'true', sessionUserId, - creationAllowed: settings.allowRegister, + creationAllowed, existingAccount, linkEmailOwnerUserId, allowAutoLink, @@ -474,7 +506,8 @@ export async function processIdpCallback( // so addHumanUser always receives a concrete org. Raw organization is undefined on a // bare (no ?organization=) flow, which causes Zitadel's FAILED_PRECONDITION. const result = await registerAndLinkIdp(provider, entries, { - email: decision.draft.email ?? '', + // registerEmail is the `+` alias on the allow-list path (ADR 007), else the IdP email. + email: registerEmail ?? decision.draft.email ?? '', firstName, lastName, organization: callbackOrg, @@ -488,6 +521,14 @@ export async function processIdpCallback( userAgent: userAgentFromRequest(request, fingerprintId), deviceTrackingToken, }); + // Audit the created account (no email — PII guard). viaDomainAllowlist/aliased are the + // TEMPORARY ADR 007 fields; they read false everywhere the flag is unset. + logAuthEvent('idp.register', 'success', { + requestId, + idpId: decision.link.idpId, + viaDomainAllowlist: allowlisted === true, + aliased, + }); const lastUsedCookie = await serializeLastUsedLogin(`idp:${decision.link.idpId}`); const passkeyHintCookie = result.loginName ? await serializePasskeyHint(result.loginName) diff --git a/app/server/infra/env.server.ts b/app/server/infra/env.server.ts index 4ab43255b..6ccf1b073 100644 --- a/app/server/infra/env.server.ts +++ b/app/server/infra/env.server.ts @@ -157,6 +157,24 @@ const schema = z .string() .optional() .transform((v) => v === 'true'), + // TEMPORARY (staging dual-org interim) — see app/resources/sso/idp-auto-create-allowlist.ts + // and ADR 007. Comma-separated email domains whose IdP-VERIFIED identities may auto-create a + // user in an org whose login policy disallows registration. Unset/empty (the default, and + // production) ⇒ fully off: the SSO callback runs its pre-existing logic untouched. + IDP_AUTO_CREATE_EMAIL_DOMAINS: z.string().optional(), + // TEMPORARY — the org id(s) the door applies to (comma-separated). REQUIRED alongside the + // domain list: the callback's target org can fall back to the raw ?organization= query param, + // so without this pin any registration-off org on the instance would become a valid + // self-provisioning target. Unset ⇒ the feature is off even if the domain list is set. + IDP_AUTO_CREATE_ORGS: z.string().optional(), + // TEMPORARY — companion to the above: the `+` inserted into the local part when the + // email already owns a user in ANOTHER org (Zitadel usernames are instance-unique). + // Defaults to 'staff'. Restricted to local-part-safe characters so a misconfigured value + // fails at boot instead of as an opaque registration error. + IDP_AUTO_CREATE_ALIAS_TAG: z + .string() + .regex(/^[A-Za-z0-9._-]*$/, 'IDP_AUTO_CREATE_ALIAS_TAG: use letters, digits, . _ - only') + .optional(), // CSP `frame-ancestors` override. Unset ⇒ 'none' (secure default — the auth UI is // not embeddable; X-Frame-Options: DENY is kept in lock-step). Set to a space/comma- // separated allowlist of full origins (e.g. "https://staging.portal.example.com") in @@ -284,6 +302,16 @@ const schema = z .filter(Boolean) : [], AUTH_EMAIL_VERIFICATION_REQUIRED: requireEmailVerification, + // TEMPORARY (ADR 007): parsed allow-list (lower-cased, empty = off) and alias tag. + IDP_AUTO_CREATE_EMAIL_DOMAINS: (v.IDP_AUTO_CREATE_EMAIL_DOMAINS ?? '') + .split(/[,\s]+/) + .map((d) => d.trim().toLowerCase()) + .filter(Boolean), + IDP_AUTO_CREATE_ORGS: (v.IDP_AUTO_CREATE_ORGS ?? '') + .split(/[,\s]+/) + .map((o) => o.trim()) + .filter(Boolean), + IDP_AUTO_CREATE_ALIAS_TAG: (v.IDP_AUTO_CREATE_ALIAS_TAG ?? '').trim() || 'staff', // SENTRY_TRACES_SAMPLE_RATE was already transformed to a number by the .pipe() above; // preserve the already-parsed value (spread covers it from `v`). // PUBLIC_ORIGIN: no default — carried through by `...v`. It is `undefined` only in diff --git a/cypress/component/resources/sso/idp-auto-create-allowlist.cy.ts b/cypress/component/resources/sso/idp-auto-create-allowlist.cy.ts new file mode 100644 index 000000000..1f4c6a9aa --- /dev/null +++ b/cypress/component/resources/sso/idp-auto-create-allowlist.cy.ts @@ -0,0 +1,232 @@ +// cypress/component/resources/sso/idp-auto-create-allowlist.cy.ts +// +// TEMPORARY (staging dual-org interim) — see app/resources/sso/idp-auto-create-allowlist.ts and +// docs/architecture/adrs/007-staging-idp-auto-create-allowlist.md. Delete with that module. +// +// An org whose login policy disallows registration (the staff org) still lets an IdP-VERIFIED +// email from an allow-listed domain auto-create a user, behind IDP_AUTO_CREATE_EMAIL_DOMAINS. +// When that same email already owns a user in ANOTHER org (Zitadel usernames are unique +// instance-wide and auth-ui uses the email as the username), the user is created under the +// `local+@domain` alias instead — the convention admins applied by hand until now. +// +// The OFF state is pinned first: with the flag unset, every case below is today's +// creation-disabled dead end, so production (which never sets the flag) is untouched. +// +// Node-bound (real signed cookies, DI'd IdP intent) → cy.task node-spec harness. +import { callService, type AuditEvent, type Scenario } from '../../../support/node/call-service'; + +const STAFF_ORG = 'org-staff'; +const CLOUD_ORG = 'org-cloud'; +const FLAGS_ON = { + IDP_AUTO_CREATE_EMAIL_DOMAINS: 'datum.net', + IDP_AUTO_CREATE_ORGS: STAFF_ORG, + ALLOW_IDP_AUTO_LINK: 'true', +}; + +function intent(email: string, emailVerified = true): Scenario['idpIntent'] { + return { + userId: null, + information: { idpId: 'idp-sso', idpUserId: `g-${email}`, idpUserName: email }, + draft: { email, firstName: 'New', lastName: 'Staff', emailVerified }, + }; +} +// The staff portal pins its org: the callback URL carries ?organization=. +const CB = `https://auth.localtest.me/sso/google/callback?id=intent-1&token=tok-1&organization=${STAFF_ORG}`; +const REGISTRATION_OFF = { allowRegister: false }; +const isSignedInOrAuthorize = (loc: string) => loc === '/signed-in' || loc.startsWith('/authorize'); + +function registerCalls(v: { calls?: Record }) { + return (v.calls?.['register'] ?? []) as Array<[{ email?: string; orgId?: string }]>; +} +function find(audit: AuditEvent[], event: string, outcome: string) { + return audit.find((e) => e.event === event && e.outcome === outcome); +} + +describe('IdP auto-create allow-list — OFF (flag unset): registration-off orgs stay closed', () => { + it('a verified allow-listed-domain email still gets creation-disabled and nothing is registered', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + seed: {}, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register'], + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location ?? '').to.include('reason=creation-disabled'); + expect(registerCalls(v).length, 'no register call').to.equal(0); + }); + }); +}); + +describe('IdP auto-create allow-list — ON (IDP_AUTO_CREATE_EMAIL_DOMAINS=datum.net)', () => { + it('creates and signs in a verified @datum.net identity in the registration-off org', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: {}, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register'], + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(isSignedInOrAuthorize(v.response?.location ?? ''), 'signed in').to.equal(true); + const [call] = registerCalls(v); + expect(call?.[0]?.email, 'registered under the real email').to.equal('new@datum.net'); + expect(call?.[0]?.orgId, 'registered into the pinned org').to.equal(STAFF_ORG); + const ev = find(v.audit, 'idp.register', 'success'); + expect(ev?.viaDomainAllowlist, 'audit marks the allow-list door').to.equal(true); + expect(ev?.aliased, 'no alias needed').to.equal(false); + }); + }); + + it('a domain outside the allow-list stays creation-disabled', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: {}, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('someone@gmail.com'), + request: { url: CB }, + recordCalls: ['register'], + }).then((v) => { + expect(v.response?.location ?? '').to.include('reason=creation-disabled'); + expect(registerCalls(v).length).to.equal(0); + }); + }); + + it('an allow-listed domain the IdP did NOT verify stays creation-disabled', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: {}, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net', false), + request: { url: CB }, + recordCalls: ['register'], + }).then((v) => { + expect(v.response?.location ?? '').to.include('reason=creation-disabled'); + expect(registerCalls(v).length).to.equal(0); + }); + }); + + it('email already owned by a user in ANOTHER org → registers the +staff alias in the pinned org', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: { users: [{ id: 'u-cloud', loginName: 'new@datum.net', orgId: CLOUD_ORG }] }, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register', 'addIdpLink'], + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(isSignedInOrAuthorize(v.response?.location ?? ''), 'signed in').to.equal(true); + const [call] = registerCalls(v); + expect(call?.[0]?.email, 'alias username/email').to.equal('new+staff@datum.net'); + expect(call?.[0]?.orgId).to.equal(STAFF_ORG); + expect(find(v.audit, 'idp.register', 'success')?.aliased).to.equal(true); + // The other org's user is NOT touched: no link is attached to u-cloud. + const links = (v.calls?.['addIdpLink'] ?? []) as Array<[string]>; + expect( + links.some((l) => l[0] === 'u-cloud'), + 'no link onto the other org user' + ).to.equal(false); + }); + }); + + it('alias already exists in the pinned org (passwordless) → auto-links it instead of registering', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: { + users: [ + { id: 'u-cloud', loginName: 'new@datum.net', orgId: CLOUD_ORG }, + { id: 'u-alias', loginName: 'new+staff@datum.net', orgId: STAFF_ORG }, + ], + }, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register', 'addIdpLink'], + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(isSignedInOrAuthorize(v.response?.location ?? ''), 'signed in').to.equal(true); + expect(registerCalls(v).length, 'no new user').to.equal(0); + const links = (v.calls?.['addIdpLink'] ?? []) as Array<[string]>; + expect(links[0]?.[0], 'linked onto the existing alias user').to.equal('u-alias'); + }); + }); + + it('pinned org NOT in IDP_AUTO_CREATE_ORGS → creation-disabled even for an allow-listed domain', () => { + // The door is confined to the listed org(s): the callback's org falls back to the raw + // ?organization= query param, so without this pin any registration-off org on the instance + // (including per-project machine-account orgs) would become a valid self-provisioning target. + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: { ...FLAGS_ON, IDP_AUTO_CREATE_ORGS: 'org-some-other' }, + seed: {}, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register'], + }).then((v) => { + expect(v.response?.location ?? '').to.include('reason=creation-disabled'); + expect(registerCalls(v).length).to.equal(0); + }); + }); + + it('alias exists only in a THIRD org → not treated as the target org account; fails closed as registration-conflict', () => { + // Proves the alias lookup is scoped to the pinned org: linking onto a user in another org + // would mint an identity the pinned request can never finalize (org guard), so the plain + // username being taken instance-wide must surface as a conflict instead. + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: { + users: [ + { id: 'u-cloud', loginName: 'new@datum.net', orgId: CLOUD_ORG }, + { id: 'u-third', loginName: 'new+staff@datum.net', orgId: 'org-third' }, + ], + }, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register', 'addIdpLink'], + }).then((v) => { + expect(v.response?.location ?? '').to.include('reason=registration-conflict'); + const links = (v.calls?.['addIdpLink'] ?? []) as Array<[string]>; + expect( + links.some((l) => l[0] === 'u-third'), + 'no link onto the third-org user' + ).to.equal(false); + }); + }); + + it('same-email user in the SAME org (passwordless) → existing auto-link path, no alias', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: FLAGS_ON, + seed: { users: [{ id: 'u-staff', loginName: 'new@datum.net', orgId: STAFF_ORG }] }, + mockLoginSettings: REGISTRATION_OFF, + idpIntent: intent('new@datum.net'), + request: { url: CB }, + recordCalls: ['register', 'addIdpLink'], + }).then((v) => { + expect(isSignedInOrAuthorize(v.response?.location ?? ''), 'signed in').to.equal(true); + expect(registerCalls(v).length).to.equal(0); + const links = (v.calls?.['addIdpLink'] ?? []) as Array<[string]>; + expect(links[0]?.[0]).to.equal('u-staff'); + }); + }); +}); diff --git a/docs/architecture/adrs/007-staging-idp-auto-create-allowlist.md b/docs/architecture/adrs/007-staging-idp-auto-create-allowlist.md new file mode 100644 index 000000000..3d1843f2b --- /dev/null +++ b/docs/architecture/adrs/007-staging-idp-auto-create-allowlist.md @@ -0,0 +1,82 @@ +# 007. Staging IdP auto-create allow-list (temporary) + +- **Status:** Accepted, with a sunset +- **Date:** 2026-09-22 +- **Sunset:** delete when staging runs one Zitadel org for humans, as production does. Design for + that migration: `docs/superpowers/specs/2026-09-21-staging-single-org-migration-design.md`. + +## Context + +Staging pins the staff portal to a second Zitadel org, "Datum Technology, Inc", whose login policy +disallows registration. Production uses one org. Two consequences drove this decision: + +- A new staff member's first Google sign-in on staging ends on `creation-disabled`, because the + SSO callback refuses creation before it looks at anything else. Admins responded by creating + users in the Zitadel console by hand, which forces password sign-in, while the team wants Google. +- Zitadel usernames are unique across the instance and auth-ui uses the email as the username. + An employee who already has a Datum Cloud user for `name@datum.net` cannot get a second user + with that username in the staff org. The hand-made workaround is the alias `name+staff@datum.net`. + +The staff portal's real access control is the milo `staff-users` group; the org pin adds none. +The proper fix is the single-org migration. This ADR covers the interim. + +## Decision + +Three env flags, all unset by default and never set in production, open a single door in the SSO +callback, implemented in `app/resources/sso/idp-auto-create-allowlist.ts` and wired at one call +site in `app/resources/sso/sso-callback.ts`: + +| Flag | Meaning | Default | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `IDP_AUTO_CREATE_EMAIL_DOMAINS` | Comma-separated email domains. An IdP-**verified** email on one of them may auto-create a user in an org whose policy disallows registration. | unset, feature off | +| `IDP_AUTO_CREATE_ORGS` | Comma-separated Zitadel org ids the door applies to. The callback's target org can fall back to the raw `?organization=` query param, so without this pin any registration-off org on the instance, including the per-project machine-account orgs, would be a valid self-provisioning target. | unset, feature off | +| `IDP_AUTO_CREATE_ALIAS_TAG` | The `+` inserted into the local part when the plain email already owns a user in **another** org. | `staff` | + +Behaviour with the flag set: + +1. `creationAllowed` becomes the org's `allowRegister` **or** "verified email on an allow-listed + domain". The same value gates the same-email lookup, so a collision still resolves through the + ADR 004 rules (`auto-link`, `link-needs-auth`, `account-exists`). +2. On the allow-list path the ownership lookup is instance-wide. A user in the target org, or in + an unknown org, is treated as the existing account. A user in another org means the plain + username is taken, so the alias is used unless the alias already exists in the target org, in + which case that alias user is the existing account. +3. The `idp.register` success event carries `viaDomainAllowlist` and `aliased`, both `false` + wherever the flag is unset. + +Staging sets `IDP_AUTO_CREATE_EMAIL_DOMAINS=datum.net`, +`IDP_AUTO_CREATE_ORGS=325848471661779545` and `ALLOW_IDP_AUTO_LINK=true`, so a +staff member onboarded at the staff portal first gets one identity that the cloud portal later +auto-links (its un-pinned lookup finds the same-email user and the account is passwordless). + +## Deliberate bends of earlier ADRs + +- **ADR 005** says an explicit org scopes user lookup. The allow-list path looks instance-wide + once, because its question is precisely whether another org owns the email. +- **ADR 004** keeps its guards. The flags decide whether the door exists; verified email and + passwordless account are still required for any automatic link. +- Google Workspace delivers plus-addressed mail to the base mailbox, so the IdP's verification + of `name@datum.net` is carried over to `name+staff@datum.net`. This assumption is why the + feature is scoped to allow-listed company domains. + +## Consequences + +- Password self-signup stays off and the sign-up link stays hidden: both key on `allowRegister`. +- The staff-users group remains the only authorization gate. +- Every user created through the alias path adds to the set the migration must merge, by no more + than the manual process did. +- The Zitadel adapter returns no user when an identifier matches more than one (ADR 005's + fail-closed rule). On this path that reads as "no owner", so the plain email is attempted and + Zitadel's `ALREADY_EXISTS` surfaces as `registration-conflict`. Rare, and it fails closed. +- Removal is mechanical: delete the module, the one call site, the three flag declarations and + their parsed exports, the spec `cypress/component/resources/sso/idp-auto-create-allowlist.cy.ts`, + the two configuration rows, and the staging env lines in infra. This ADR then moves to + Superseded. + +## References + +- `app/resources/sso/idp-auto-create-allowlist.ts` +- `app/resources/sso/sso-callback.ts` +- `app/server/infra/env.server.ts` +- [ADR 004](./004-idp-linking-flags.md), [ADR 005](./005-login-org-scoping.md) +- auth-ui#140, datum-cloud/infra#5230 diff --git a/docs/architecture/adrs/README.md b/docs/architecture/adrs/README.md index 1e8bf1cf0..0600f4ed5 100644 --- a/docs/architecture/adrs/README.md +++ b/docs/architecture/adrs/README.md @@ -36,6 +36,7 @@ Do **not** write one for library bumps, refactors with no behavioural consequenc | [004](./004-idp-linking-flags.md) | IdP linking flags | Accepted | 2026-07-01 | | [005](./005-login-org-scoping.md) | Login org scoping | Accepted | 2026-07-01 | | [006](./006-signup-provisioning-invariant.md) | Signup provisioning invariant | Accepted | 2026-07-02 | +| [007](./007-staging-idp-auto-create-allowlist.md) | IdP auto-create allow-list | Accepted | 2026-09-22 | ## Template diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 4558bcc4d..75dc0d96a 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -40,6 +40,9 @@ All five default to **false** (fail-closed). Only the exact string `true` enable | `ALLOW_IDP_AUTO_LINK` | No | `false` | Auto-links an external IdP identity into an existing same-email account during login/register. Off means a same-email collision is a hard `account-exists` error and the owner must link the IdP from the signed-in `/sso` screen. | | `ALLOW_IDP_LINK_ANY_EMAIL` | No | `false` | Lets the explicit SSO link ceremony attach a fresh external identity regardless of its email address. Off applies the strict gate: the IdP-verified email must already be owned by the session user. | | `ALLOW_IDP_UNLINK` | No | `false` | Permits unlinking an identity provider from an account. | +| `IDP_AUTO_CREATE_EMAIL_DOMAINS` | No | unset | **Temporary, [ADR 007](../architecture/adrs/007-staging-idp-auto-create-allowlist.md).** Comma-separated email domains whose IdP-verified identities may auto-create a user in an org whose login policy disallows registration. Unset means the feature is off. | +| `IDP_AUTO_CREATE_ORGS` | No | unset | **Temporary, ADR 007.** Comma-separated Zitadel org ids the door applies to. Required together with the domain list; unset means the feature is off. | +| `IDP_AUTO_CREATE_ALIAS_TAG` | No | `staff` | **Temporary, ADR 007.** The `+` inserted into the email's local part when that email already owns a user in another org. Only read when the domain list is set. | ## Routing