diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 850d58ff26..502c8f6e2a 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -167,7 +167,11 @@ export const SysUser = ObjectSchema.create({ locations: ['record_header', 'record_more', 'record_section'], type: 'api', target: '/api/v1/auth/change-password', - visible: 'record.id == ctx.user.id', + // Managed (IdP-provisioned) users hold no local credential — hide the + // password form so they can't self-mint a password that bypasses + // enforced SSO. The break-glass owner (env-native, or flipped back when + // their break-glass password is set) keeps it. ADR-0024 D4/D5.2. + visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"', successMessage: 'Password changed', refreshAfter: false, params: [ @@ -184,7 +188,9 @@ export const SysUser = ObjectSchema.create({ locations: ['record_header', 'record_more', 'record_section'], type: 'api', target: '/api/v1/auth/change-email', - visible: 'record.id == ctx.user.id', + // A managed user's email is owned by the IdP — a local change would + // desync. Hide for IdP-provisioned; env-native users keep it. + visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"', successMessage: 'Verification email sent — check the new address to confirm.', refreshAfter: false, params: [ @@ -215,7 +221,9 @@ export const SysUser = ObjectSchema.create({ locations: ['record_more', 'record_section'], type: 'api', target: '/api/v1/auth/delete-user', - visible: 'record.id == ctx.user.id', + // Self-delete needs a local password; managed users are deprovisioned + // via the IdP (org-removal / SCIM), not local self-service. Hide for them. + visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"', confirmText: 'Permanently delete your account? This cannot be undone — all your sessions will be terminated and all data you own will be removed per the configured retention policy.', successMessage: 'Account deleted', refreshAfter: false, @@ -299,7 +307,7 @@ export const SysUser = ObjectSchema.create({ name: 'all_users', label: 'All Users', data: { provider: 'object', object: 'sys_user' }, - columns: ['name', 'email', 'email_verified', 'two_factor_enabled', 'created_at'], + columns: ['name', 'email', 'email_verified', 'source', 'two_factor_enabled', 'created_at'], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 50 }, }, @@ -431,6 +439,29 @@ export const SysUser = ObjectSchema.create({ }), // ── System (auto-managed, hidden from create/edit forms) ───── + // Identity provenance (ADR-0024 D4). `idp_provisioned` users were + // JIT-created on first federated login (a `sys_account` exists for an + // external/OIDC provider — e.g. the cloud-as-IdP `objectstack-cloud` + // provider, or a customer's own IdP); `env_native` users registered + // locally (email/password) or are app end-users. Stamped automatically by + // the AuthManager `account.create.after` hook — never edited by hand. + // Drives the managed-vs-native user-mgmt UI gating (the password / + // identity-edit actions hide for managed users, who hold no local + // credential) and is the marker SCIM lifecycle keys off. Owned by objectql + // (better-auth is oblivious to this column — like `ai_access`). + source: Field.select({ + label: 'Identity Source', + required: false, + readonly: true, + group: 'System', + defaultValue: 'env_native', + options: [ + { label: 'IdP-Provisioned', value: 'idp_provisioned' }, + { label: 'Env-Native', value: 'env_native' }, + ], + description: 'How this identity was created — idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit.', + }), + id: Field.text({ label: 'User ID', required: true, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index f5340fb570..a2eec0b970 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1047,6 +1047,7 @@ describe('AuthManager', () => { organization: true, oidcProvider: false, sso: false, + ssoEnforced: false, deviceAuthorization: false, admin: false, multiOrgEnabled: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e3f2658b11..37d4e904e3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -149,6 +149,16 @@ function readDisableSignUpEnv(): boolean | undefined { return readBooleanEnv('OS_DISABLE_SIGNUP'); } +/** + * SSO-only ("enforced") login mode from the deployment env. Self-host ops set + * `OS_AUTH_SSO_ONLY=true` to lock the team to the configured IdP (parity with + * the `OS_DISABLE_SIGNUP` self-host knob). The cloud runtime drives the same + * behaviour per-env via the `ssoOnlyMode` config field instead. + */ +function readSsoOnlyEnv(): boolean | undefined { + return readBooleanEnv('OS_AUTH_SSO_ONLY'); +} + /** * Extended options for AuthManager */ @@ -394,7 +404,12 @@ export class AuthManager { // lock the registration policy without relying on UI state. emailAndPassword: (() => { const disableSignUpFromEnv = readDisableSignUpEnv(); - const effectiveDisableSignUp = disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp; + // SSO-only ("enforced") forces self-registration off (the managed team + // signs in via the IdP). `enabled` stays true so the break-glass + // password endpoint keeps working for the env owner / local admin. + const effectiveDisableSignUp = this.resolveSsoOnly() + ? true + : (disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp); return { enabled: this.config.emailAndPassword?.enabled ?? true, ...(passwordHasher ? { password: passwordHasher } : {}), @@ -502,8 +517,10 @@ export class AuthManager { // Database hooks (fired by better-auth's adapter writes — these run // for SSO JIT-provisioning too, unlike kernel-level ObjectQL - // middleware which better-auth's adapter bypasses). - ...(this.config.databaseHooks ? { databaseHooks: this.config.databaseHooks } : {}), + // middleware which better-auth's adapter bypasses). The framework's + // identity-source stamp (`account.create.after`) is always composed in, + // preserving any host-supplied hooks. + databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks), // Bootstrap bypass for `disableSignUp`. The first-run owner wizard // (`/_account/setup`) calls `POST /auth/sign-up/email` to create @@ -577,6 +594,68 @@ export class AuthManager { } return; } + + // ── Break-glass: never remove the LAST local-password login ────── + // Under enforced SSO the managed team holds no local credential; the + // env owner / a local admin keeps one as the break-glass escape hatch + // so an IdP outage can never lock the org out. Refuse to delete or + // ban the last user holding a `credential` account. Generic over the + // IdP. Managed (credential-less) users are unaffected. Fail-open on + // lookup hiccups (a transient query error must not block legit ops). + if ( + ctx?.path === '/delete-user' || + ctx?.path === '/admin/remove-user' || + ctx?.path === '/admin/ban-user' + ) { + let isLastLocalCredential = false; + try { + const adapter = ctx.context.adapter; + let targetId: string | undefined = ctx?.body?.userId ?? ctx?.body?.user_id; + if (!targetId && ctx.path === '/delete-user') { + const { getSessionFromCtx } = await import('better-auth/api'); + const s: any = await getSessionFromCtx(ctx as any).catch(() => null); + targetId = s?.user?.id ?? s?.session?.userId; + } + if (targetId) { + // Only guard when the target actually holds a local credential — + // removing a credential-less (managed) user can't cause lockout. + const targetCred = await adapter.findOne({ + model: 'account', + where: [ + { field: 'userId', value: targetId }, + { field: 'providerId', value: 'credential' }, + ], + }); + if (targetCred) { + const creds: any[] = await adapter.findMany({ + model: 'account', + where: [{ field: 'providerId', value: 'credential' }], + }); + const otherHolders = new Set( + (creds ?? []) + .map((a: any) => a?.userId ?? a?.user_id) + .filter((id: any) => id && id !== targetId), + ); + isLastLocalCredential = otherHolders.size === 0; + } + } + } catch { + // Fail-open — never block a legitimate op on a lookup error. + } + if (isLastLocalCredential) { + const { APIError } = await import('better-auth/api'); + throw new APIError('CONFLICT', { + message: + 'Cannot remove the last local password login. At least one ' + + 'break-glass account with a password must remain so an identity-' + + 'provider outage can never lock the organization out. Add another ' + + 'local password first, then retry.', + code: 'LAST_LOCAL_CREDENTIAL', + }); + } + // fall through to better-auth's own handler + } + if (ctx?.path !== '/sign-up/email') return; const ep = ctx?.context?.options?.emailAndPassword; if (!ep?.disableSignUp) return; @@ -1447,6 +1526,19 @@ export class AuthManager { // AuthPluginConfig. // --------------------------------------------------------------------------- + /** + * SSO-only ("enforced") login mode: the login UI hides the local password + * form + self-registration so the team signs in via the IdP only. + * `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob — + * parity with the `disableSignUp` env override — so a deployment can force + * it regardless of the per-env/config value. Break-glass is preserved: this + * NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp` + * and signals the UI to hide the password form. Generic over the IdP. + */ + private resolveSsoOnly(): boolean { + return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false); + } + getPublicConfig() { // Extract social providers info (without sensitive data) const socialProviders = []; @@ -1493,9 +1585,13 @@ export class AuthManager { // `emailAndPassword.disableSignUp` (default `false`). const emailPasswordConfig: Partial = this.config.emailAndPassword ?? {}; const disableSignUpFromEnv = readDisableSignUpEnv(); + // SSO-only ("enforced") hides the local password form + self-registration. + // `enabled` stays true (break-glass), but signup is forced off and the UI + // suppresses the password form via `features.ssoEnforced` below. + const ssoOnly = this.resolveSsoOnly(); const emailPassword = { enabled: emailPasswordConfig.enabled !== false, // Default to true - disableSignUp: disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false, + disableSignUp: ssoOnly ? true : (disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false), requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false, }; @@ -1550,6 +1646,10 @@ export class AuthManager { // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button // both when SSO is off AND when it's on but no IdP exists yet. sso: this.isSsoWired(), + // SSO-only ("enforced"): tell the login UI to hide the local password + // form + self-registration. A break-glass "use a password" link remains + // for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`. + ssoEnforced: ssoOnly, deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? false, ...(termsUrl ? { termsUrl } : {}), @@ -1599,6 +1699,96 @@ export class AuthManager { } } + /** + * Compose the framework's identity-source stamp (`account.create.after`) + * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes + * `user.create.after` (personal-org provisioning) + `session.create.before` + * (active-org) — different model/op, so no collision — but if a host ever + * adds its own `account.create.after` we chain it after the stamp rather + * than silently dropping one. + */ + private composeDatabaseHooks( + host?: BetterAuthOptions['databaseHooks'], + ): BetterAuthOptions['databaseHooks'] { + const stamp = (account: any, ctx: any) => this.stampIdentitySource(account, ctx); + const hostAccountAfter = (host as any)?.account?.create?.after; + const after = hostAccountAfter + ? async (account: any, ctx: any) => { + await stamp(account, ctx); + return hostAccountAfter(account, ctx); + } + : stamp; + return { + ...(host ?? {}), + account: { + ...((host as any)?.account ?? {}), + create: { + ...((host as any)?.account?.create ?? {}), + after, + }, + }, + } as BetterAuthOptions['databaseHooks']; + } + + /** + * Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked. + * Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`) + * user holds no local credential, so the password / identity-edit actions + * hide for them — preventing a managed user from self-minting a local + * password that would bypass enforced SSO. + * + * Two cases, both break-glass safe and idempotent (only writes on a real + * change, so trackHistory stays quiet): + * + * • A **federated** account (any non-`credential` provider — the cloud-as-IdP + * `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is + * linked AND the user holds NO local credential → mark `idp-provisioned`. + * A user who already has a `credential` account (an env-native user who + * linked SSO) is left `env-native` — they keep a usable password. + * + * • A **credential** account is created (local signup, or the break-glass + * owner's password set via set-initial-password — which can land AFTER the + * first SSO link) → ensure `env-native`. This flips a previously-stamped + * owner back, so the break-glass admin never loses self-service password + * management. + * + * Best-effort: any failure leaves the prior value (the gate fails open — a + * managed user might transiently show a password action that simply errors — + * never a hard login failure). + */ + private async stampIdentitySource(account: any, _ctx?: unknown): Promise { + try { + const providerId = account?.providerId ?? account?.provider_id; + const userId = account?.userId ?? account?.user_id; + if (!userId || !providerId) return; + const engine = this.getDataEngine(); + if (!engine) return; + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + + if (providerId === 'credential') { + // Gained a local password → env-native. Only write if currently + // managed (avoids a no-op history row on every local signup). + const u = await engine.findOne('sys_user', { + filter: { id: userId }, fields: ['id', 'source'], context: SYSTEM_CTX, + } as any); + if (u && u.source === 'idp_provisioned') { + await engine.update('sys_user', { id: userId, source: 'env_native' }, { context: SYSTEM_CTX } as any); + } + return; + } + + // Federated link → managed, unless a local credential already exists. + const credentialCount = await engine.count('sys_account', { + filter: { user_id: userId, provider_id: 'credential' }, + context: SYSTEM_CTX, + } as any); + if (typeof credentialCount === 'number' && credentialCount > 0) return; + await engine.update('sys_user', { id: userId, source: 'idp_provisioned' }, { context: SYSTEM_CTX } as any); + } catch { + // Provenance stamp must never break federated login. Leave the prior value. + } + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 1bd96ee7ea..fbab4378e3 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -198,6 +198,9 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ passkeys: z.boolean().default(false).describe('Passkey/WebAuthn support enabled'), magicLink: z.boolean().default(false).describe('Magic link login enabled'), organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'), + ssoEnforced: z.boolean().optional().describe( + 'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)', + ), })); /** diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index ff5f527e34..dce08b9870 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -278,6 +278,18 @@ export const AuthConfigSchema = lazySchema(() => z.object({ emailAndPassword: EmailAndPasswordConfigSchema, emailVerification: EmailVerificationConfigSchema, advanced: AdvancedAuthConfigSchema, + /** + * SSO-only ("enforced") login mode. When `true`, the login UI hides the + * local email/password form and self-registration so the team signs in via + * the configured IdP only (cloud-as-IdP, or an external OIDC/SAML provider). + * The break-glass password endpoint stays enabled — managed (IdP-provisioned) + * users simply hold no local credential, while the env owner retains a + * password escape hatch. Generic over the IdP; orthogonal to which providers + * are wired. Self-host can also set this via `OS_AUTH_SSO_ONLY=true`. + */ + ssoOnlyMode: z.boolean().optional().describe( + 'SSO-only login: hide the local password form + self-registration (the break-glass password endpoint stays enabled)', + ), mutualTls: MutualTLSConfigSchema.optional().describe('Mutual TLS (mTLS) configuration'), }).catchall(z.unknown()));