Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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: [
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
},
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,7 @@ describe('AuthManager', () => {
organization: true,
oidcProvider: false,
sso: false,
ssoEnforced: false,
deviceAuthorization: false,
admin: false,
multiOrgEnabled: false,
Expand Down
198 changes: 194 additions & 4 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -1493,9 +1585,13 @@ export class AuthManager {
// `emailAndPassword.disableSignUp` (default `false`).
const emailPasswordConfig: Partial<EmailAndPasswordConfig> = 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,
};

Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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<void> {
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
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/src/api/auth-endpoints.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
),
}));

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/spec/src/system/auth-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()));

Expand Down