diff --git a/.changeset/auth-sso-boot-report-gate.md b/.changeset/auth-sso-boot-report-gate.md new file mode 100644 index 0000000000..9235c0ecbc --- /dev/null +++ b/.changeset/auth-sso-boot-report-gate.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-auth': patch +--- + +Gate the `no_sign_in_account_at_boot` boot report on whether the deployment has a delegated sign-in path. + +The report fires on one store shape — human `sys_user` rows, zero `sys_account` rows — and calls it unrecoverable. On a deployment whose sign-in is delegated to an identity provider that shape is the healthy resting state: `ssoOnlyMode` states it in the auth config contract ("managed (IdP-provisioned) users simply hold no local credential") and names cloud-as-IdP. Such a kernel logged the report at `error` on every boot, including boots that had just served a successful SSO sign-in. + +The report now also reads the runtime's sign-in wiring — SSO-only mode declared, a configured social/OIDC provider, or enterprise SSO with at least one registered `sys_sso_provider` — and stays silent at `error` when one of them holds, recording the shape at `debug` under the same grep token with the reason named. + +Unchanged: `probeSignInAccountsPresence` keeps its existence-only predicate, and a deployment with no delegated sign-in path — including one that merely switched the SSO plugin on with no identity provider registered — still reports at `error`. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index b3f42b95c3..e888118557 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -82,8 +82,10 @@ import { type WalledOwnerAccountState, } from './walled-owner-verification-path.js'; import { + probeSignInPathWiring, probeSignInReachability, reportIfNoSignInAccountExists, + type SignInPathConfigView, } from './boot-sign-in-reachability.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { @@ -1002,7 +1004,7 @@ export class AuthPlugin implements Plugin { // `AuthManager` without ever registering the kernel `email` service, and // the sibling hook below injects the service into it. Reading BOTH makes // this hook's answer independent of hook registration order. - let pub: { socialProviders?: unknown[]; features?: { sso?: boolean } } | undefined; + let pub: SignInPathConfigView | undefined; try { pub = this.authManager?.getPublicConfig(); } catch { pub = undefined; } const hasEmailTransport = !!emailSvc || !!this.authManager?.hasEmailTransport(); const hasFederatedSignIn = @@ -1025,7 +1027,16 @@ export class AuthPlugin implements Plugin { // and the answer handed to the walled-owner probe, so no boot pages // `sys_user` twice. Cost on a fresh store is a single bounded page. const reachability = await probeSignInReachability(ql); - const deadEnd = reportIfNoSignInAccountExists(reachability, ctx.logger); + // [#15074] …and the fact that decides whether "humans, zero accounts" is + // a dead end AT ALL on this deployment: does it sign people in through an + // identity provider, which needs no `sys_account` row of its own? On a + // platform-SSO tenant kernel that population is the HEALTHY one, and the + // report's "NOBODY CAN SIGN IN" was false on every boot. The resolver + // pays for its bounded provider read only when the answer can change what + // is reported; a deployment with no delegated path is untouched and still + // reports at `error`. + const signInPath = await probeSignInPathWiring(reachability, pub, ql); + const deadEnd = reportIfNoSignInAccountExists(reachability, ctx.logger, signInPath); let ownerAccountState: WalledOwnerAccountState = 'unknown'; if ( diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.sso-gate.test.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.sso-gate.test.ts new file mode 100644 index 0000000000..619b807685 --- /dev/null +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.sso-gate.test.ts @@ -0,0 +1,456 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15074] The platform-SSO tenant kernel that reported its HEALTHY state as an + * unrecoverable dead end. + * + * `no_sign_in_account_at_boot` fires on ONE store shape — human `sys_user` + * rows, zero `sys_account` rows — and says of it: "NOBODY CAN SIGN IN, and the + * deployment CANNOT BE RECOVERED FROM INSIDE". On a deployment whose sign-in is + * DELEGATED to an identity provider that mission-critically does not need a + * `sys_account` row of its own, that same shape is the NORMAL resting state: + * `AuthConfigSchema.ssoOnlyMode` says so in the contract — "managed + * (IdP-provisioned) users simply hold no local credential" — and names + * `cloud-as-IdP` as one of the IdPs it is generic over. The card measured it on + * a cloud tenant environment where the very boot that emitted the ERROR had + * just served an SSO handoff. + * + * ## What this suite pins, in BOTH directions + * + * The failure this card guards against is silencing both shapes at once — a + * no-SSO deployment with humans and zero accounts is still a real dead end + * (#14495 / #14353) and must still be told, loudly. So every "quiet" case below + * is paired with a control that still reports: + * + * - delegated sign-in configured ⇒ NO `error`, and a `debug` line naming the + * path as the reason (the card's own second option); + * - nothing configured ⇒ the `error` fires exactly as before; + * - the SSO plugin merely SWITCHED ON with no IdP registered ⇒ the `error` + * still fires. A wired-but-unconfigured plugin signs nobody in, so it is not + * a sign-in path, and #14353's independence pin (`FEDERATED SIGN-IN IS + * WIRED — the neighbour stays quiet; this still reports`) keeps its meaning. + * + * ⛔ Nothing here touches {@link probeSignInAccountsPresence}'s existence-only + * predicate — that is #15718's half, and it carries an unruled maintainer + * question. This card gates the REPORT on a third fact; the probe is untouched + * and #14353's pins stay green beside it. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AuthPlugin } from './auth-plugin'; +import { + NO_SIGN_IN_ACCOUNT_AT_BOOT, + probeSignInAccountsPresence, + probeSignInPathWiring, + probeSsoProvidersPresence, + reportIfNoSignInAccountExists, + resolveDelegatedSignInPath, + resolveNoSignInAccountReport, +} from './boot-sign-in-reachability'; +import type { + BootProbeEngine, + SignInPathWiring, + SignInReachabilityFacts, +} from './boot-sign-in-reachability'; +import { WALLED_OWNER_NO_VERIFICATION_PATH } from './walled-owner-verification-path'; +import type { PluginContext } from '@objectstack/core'; + +/** The store shape this report speaks about: humans SEEN, accounts SEEN ABSENT. */ +const DEAD_END: SignInReachabilityFacts = { humanUsers: 'present', signInAccounts: 'absent' }; +const NOTHING_WIRED: SignInPathWiring = { + ssoOnlyMode: false, + socialSignIn: false, + enterpriseSso: false, +}; + +const ENV_KEYS = [ + 'OS_AUTH_SSO_ONLY', + 'OS_SSO_ENABLED', + 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', + 'OS_PLATFORM_OWNER_EMAIL', + 'OS_SEED_ADMIN', + 'OS_SEED_ADMIN_EMAIL', + 'OS_AUTH_GOOGLE_ENABLED', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'NODE_ENV', +] as const; +const SAVED: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) { + SAVED[k] = process.env[k]; + delete process.env[k]; + } +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (SAVED[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED[k]; + } +}); + +// --------------------------------------------------------------------------- +// The same recording fake store the #14353 suite uses, plus the third table +// this card's gate consults (`sys_sso_provider`). +// --------------------------------------------------------------------------- + +type Store = { + users?: Record[]; + accounts?: Record[]; + ssoProviders?: Record[]; +}; + +const engineOver = (store: Store) => { + const reads: { object: string; query: Record }[] = []; + const engine: BootProbeEngine = { + async find(object, query) { + reads.push({ object, query }); + const rows = + object === 'sys_user' + ? (store.users ?? []) + : object === 'sys_account' + ? (store.accounts ?? []) + : object === 'sys_sso_provider' + ? (store.ssoProviders ?? []) + : []; + const limit = typeof query.limit === 'number' ? query.limit : rows.length; + return rows.slice(0, limit); + }, + }; + return { engine, reads }; +}; + +const human = (i: number) => ({ id: `usr_${i}`, email: `person${i}@corp.example`, role: 'user' }); +/** The population the card measured: people provisioned by the platform, no local credential. */ +const HUMANS = [human(1), human(2), human(3)]; + +type Hooked = { event: string; handler: (...a: unknown[]) => unknown }; + +const makeCtx = (services: Record = {}) => { + const hooks: Hooked[] = []; + const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name in services) return services[name]; + if (name === 'objectql') throw new Error('no service objectql'); + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((event: string, handler: (...a: unknown[]) => unknown) => { + hooks.push({ event, handler }); + }), + trigger: vi.fn(), + logger, + getKernel: vi.fn(), + } as unknown as PluginContext; + return { ctx, hooks, logger }; +}; + +const runKernelReady = async (hooks: Hooked[]) => { + for (const h of hooks.filter((x) => x.event === 'kernel:ready')) { + // Sibling hooks need services this fake context does not carry; their + // failures are not this suite's subject. + try { await h.handler(); } catch { /* not under test */ } + } +}; + +const bootWith = async ( + store: Store, + env: Record = {}, + options: Record = {}, +) => { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + const { engine, reads } = engineOver(store); + const { ctx, hooks, logger } = makeCtx({ objectql: engine }); + const plugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + registerRoutes: false, + ...options, + }); + await plugin.init(ctx); + await plugin.start(ctx); + await runKernelReady(hooks); + const said = (fn: { mock: { calls: unknown[][] } }) => fn.mock.calls.map((c) => String(c[0])); + return { + logger, + reads, + errors: said(logger.error).filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT)), + warnings: said(logger.warn).filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT)), + debugs: said(logger.debug).filter((m) => m.includes(NO_SIGN_IN_ACCOUNT_AT_BOOT)), + }; +}; + +// --------------------------------------------------------------------------- +// Direction 1 — a delegated sign-in path is configured ⇒ the ERROR must not fire +// --------------------------------------------------------------------------- + +describe('#15074 — a deployment whose sign-in is DELEGATED is not a dead end', () => { + it('SSO-only mode via `OS_AUTH_SSO_ONLY` — humans, zero accounts, and NO error', async () => { + // The card's measured shape. `ssoOnlyMode` is the deployment DECLARING that + // its humans sign in through an IdP and hold no local credential, so + // "humans present, zero sys_account" is its healthy resting state. + const { errors, warnings } = await bootWith( + { users: HUMANS, accounts: [] }, + { OS_AUTH_SSO_ONLY: 'true' }, + ); + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + }); + + it('SSO-only mode declared in CONFIG (`ssoOnlyMode`) reaches the same verdict', async () => { + // Generic over the IdP and orthogonal to the env var: a cloud tenant kernel + // receives this through its constructed auth config, not through env. + const { errors, warnings } = await bootWith( + { users: HUMANS, accounts: [] }, + {}, + { ssoOnlyMode: true }, + ); + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + }); + + it('the suppressed report still leaves a `debug` line NAMING the reason', async () => { + // The card's own second option: "degrade to a debug line naming the SSO + // path as the reason". The grep token is unchanged, so an operator asking + // "why is this quiet" finds the answer under the same name. + const { debugs } = await bootWith( + { users: HUMANS, accounts: [] }, + { OS_AUTH_SSO_ONLY: 'true' }, + ); + expect(debugs).toHaveLength(1); + expect(debugs[0]).toMatch(/ssoOnlyMode/); + }); + + it('a configured SOCIAL provider is a sign-in path — no error', async () => { + const { errors } = await bootWith( + { users: HUMANS, accounts: [] }, + { GOOGLE_CLIENT_ID: 'gid', GOOGLE_CLIENT_SECRET: 'gsecret' }, + ); + expect(errors).toHaveLength(0); + }); + + it('enterprise SSO WITH a registered IdP is a sign-in path — no error', async () => { + // SCIM-provisioned people + a registered `sys_sso_provider` row: everyone + // signs in through the IdP and nobody holds a `sys_account` row until they + // first do. + const { errors } = await bootWith( + { users: HUMANS, accounts: [], ssoProviders: [{ id: 'ssop_1', domain: 'corp.example' }] }, + { OS_SSO_ENABLED: '1' }, + ); + expect(errors).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Direction 2 — the self-hosted dead end is UNTOUCHED and still loud +// --------------------------------------------------------------------------- + +describe('#15074 — ⛔ the no-SSO dead end is NOT silenced (#14495 / #14353)', () => { + it('NO delegated sign-in path at all — the error fires, exactly as before', async () => { + const { errors } = await bootWith({ users: HUMANS, accounts: [] }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('NOBODY CAN SIGN IN'); + }); + + it('the SSO plugin merely SWITCHED ON, no IdP registered — still reports', async () => { + // `OS_SSO_ENABLED=1` with zero `sys_sso_provider` rows mounts a route that + // signs nobody in. It is not a sign-in path, and #14353's independence pin + // says this deployment is still told. + const { errors } = await bootWith( + { users: HUMANS, accounts: [], ssoProviders: [] }, + { OS_SSO_ENABLED: '1' }, + ); + expect(errors).toHaveLength(1); + }); + + it('and it is NOT reduced to a debug line — the dead end keeps its level', async () => { + const { errors, debugs } = await bootWith({ users: HUMANS, accounts: [] }); + expect(errors).toHaveLength(1); + expect(debugs).toHaveLength(0); + }); + + it('the WALLED-OWNER NEIGHBOUR is untouched by this gate', async () => { + // Suppressing this report hands the hook back to the neighbour, exactly as + // it does for every other silent shape. The gate must not silence a second + // diagnostic on its way past — that is its own decision, on its own facts. + const { errors, logger } = await bootWith( + { users: HUMANS, accounts: [] }, + { + OS_AUTH_SSO_ONLY: 'true', + OS_TENANCY_POSTURE: 'isolated', + OS_PLATFORM_OWNER_EMAIL: 'owner@corp.example', + }, + ); + expect(errors).toHaveLength(0); + const neighbour = logger.warn.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes(WALLED_OWNER_NO_VERIFICATION_PATH)); + expect(neighbour).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// The gate, fact by fact — no I/O, no boot. +// --------------------------------------------------------------------------- + +describe('#15074 — the predicate takes a THIRD fact and defaults to LOUD', () => { + it('no wiring argument at all ⇒ the report is unchanged', () => { + // Every pre-#15074 caller passes two arguments; the dead end they describe + // must keep reporting rather than fall quiet because a parameter is absent. + expect(resolveNoSignInAccountReport(DEAD_END)).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + expect(resolveNoSignInAccountReport(DEAD_END, NOTHING_WIRED)).toContain( + NO_SIGN_IN_ACCOUNT_AT_BOOT, + ); + }); + + it.each([ + ['ssoOnlyMode', { ...NOTHING_WIRED, ssoOnlyMode: true }, /ssoOnlyMode/], + ['socialSignIn', { ...NOTHING_WIRED, socialSignIn: true }, /social\/OIDC/], + ['enterpriseSso', { ...NOTHING_WIRED, enterpriseSso: true }, /sys_sso_provider/], + ])('a delegated path via `%s` ⇒ no report, and the reason NAMES it', (_n, wiring, names) => { + expect(resolveNoSignInAccountReport(DEAD_END, wiring as SignInPathWiring)).toBeNull(); + expect(resolveDelegatedSignInPath(wiring as SignInPathWiring)).toMatch(names as RegExp); + }); + + it('nothing configured ⇒ there is no reason to name', () => { + expect(resolveDelegatedSignInPath(NOTHING_WIRED)).toBeNull(); + expect(resolveDelegatedSignInPath(undefined)).toBeNull(); + }); + + it('the gate only reaches the shape this report speaks about', () => { + // A configured IdP is not a licence to go quiet about other shapes: the two + // store facts still decide first, and `unknown` still claims nothing. + const wired: SignInPathWiring = { ...NOTHING_WIRED, ssoOnlyMode: true }; + for (const facts of [ + { humanUsers: 'absent', signInAccounts: 'unknown' }, + { humanUsers: 'unknown', signInAccounts: 'unknown' }, + { humanUsers: 'present', signInAccounts: 'present' }, + ] satisfies SignInReachabilityFacts[]) { + expect(resolveNoSignInAccountReport(facts, wired)).toBeNull(); + expect(resolveNoSignInAccountReport(facts)).toBeNull(); + } + }); + + it('the emitter records the SUPPRESSED shape at `debug` — and only that shape', () => { + const logger = { warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const wired: SignInPathWiring = { ...NOTHING_WIRED, ssoOnlyMode: true }; + + expect(reportIfNoSignInAccountExists(DEAD_END, logger, wired)).toBeNull(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(String(logger.debug.mock.calls[0][0])).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + + // An ordinary silent shape stays FULLY silent — the debug line is about the + // suppression, not about every boot. + logger.debug.mockClear(); + reportIfNoSignInAccountExists({ humanUsers: 'present', signInAccounts: 'present' }, logger, wired); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('a sink with no `debug` is not an error, and the boot survives a throwing one', () => { + const wired: SignInPathWiring = { ...NOTHING_WIRED, socialSignIn: true }; + expect(() => reportIfNoSignInAccountExists(DEAD_END, { warn: vi.fn() }, wired)).not.toThrow(); + expect(() => + reportIfNoSignInAccountExists( + DEAD_END, + { warn: vi.fn(), debug: () => { throw new Error('sink is down'); } }, + wired, + ), + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ⛔ #15718's half is NOT taken here. +// --------------------------------------------------------------------------- + +describe('#15074 — `probeSignInAccountsPresence` is left exactly as it was', () => { + it('still existence-only: ANY row answers `present`, unusable or not', async () => { + // The #15718 direction (one unusable `sys_account` row silences this report + // permanently) is an unruled maintainer question. This card gates the + // REPORT on a fact the probe never had; the probe's predicate is untouched. + const { engine } = engineOver({ + accounts: [{ id: 'acc_1', provider_id: 'credential', password: 'plaintext-authenticates-nothing' }], + }); + await expect(probeSignInAccountsPresence(engine)).resolves.toBe('present'); + }); + + it('and a configured IdP does not change what the account probe answers', async () => { + process.env.OS_AUTH_SSO_ONLY = 'true'; + const { engine } = engineOver({ users: HUMANS, accounts: [] }); + await expect(probeSignInAccountsPresence(engine)).resolves.toBe('absent'); + }); +}); + +// --------------------------------------------------------------------------- +// The provider probe, and what it costs. +// --------------------------------------------------------------------------- + +describe('#15074 — the `sys_sso_provider` probe is bounded, silent and cheap', () => { + it('answers present / absent off one bounded row read', async () => { + const { engine, reads } = engineOver({ ssoProviders: [{ id: 'ssop_1' }] }); + await expect(probeSsoProvidersPresence(engine)).resolves.toBe('present'); + expect(reads).toEqual([{ object: 'sys_sso_provider', query: { limit: 1 } }]); + + const empty = engineOver({ ssoProviders: [] }); + await expect(probeSsoProvidersPresence(empty.engine)).resolves.toBe('absent'); + }); + + it('no engine, or a store that throws ⇒ `unknown`, and it never throws', async () => { + await expect(probeSsoProvidersPresence(undefined)).resolves.toBe('unknown'); + const thrower: BootProbeEngine = { + async find() { throw new Error('store refused sys_sso_provider'); }, + }; + await expect(probeSsoProvidersPresence(thrower)).resolves.toBe('unknown'); + }); + + it('`unknown` keeps the report LOUD — an unreadable store proves no path', async () => { + const thrower: BootProbeEngine = { + async find() { throw new Error('store refused sys_sso_provider'); }, + }; + const wiring = await probeSignInPathWiring(DEAD_END, { features: { sso: true } }, thrower); + expect(wiring.enterpriseSso).toBe(false); + expect(resolveNoSignInAccountReport(DEAD_END, wiring)).toContain(NO_SIGN_IN_ACCOUNT_AT_BOOT); + }); + + it('is NOT read on a boot that could never report', async () => { + // Not the dead-end shape ⇒ the wiring cannot change anything ⇒ no read. + const { engine, reads } = engineOver({ ssoProviders: [{ id: 'ssop_1' }] }); + const wiring = await probeSignInPathWiring( + { humanUsers: 'present', signInAccounts: 'present' }, + { features: { sso: true } }, + engine, + ); + expect(wiring.enterpriseSso).toBe(false); + expect(reads).toEqual([]); + }); + + it('is NOT read when a delegated path is already proven from config', async () => { + const { engine, reads } = engineOver({ ssoProviders: [{ id: 'ssop_1' }] }); + await probeSignInPathWiring(DEAD_END, { features: { sso: true, ssoEnforced: true } }, engine); + expect(reads).toEqual([]); + + await probeSignInPathWiring( + DEAD_END, + { features: { sso: true }, socialProviders: [{ id: 'google' }] }, + engine, + ); + expect(reads).toEqual([]); + }); + + it('is not read at all when the SSO plugin is off', async () => { + const { engine, reads } = engineOver({ ssoProviders: [{ id: 'ssop_1' }] }); + const wiring = await probeSignInPathWiring(DEAD_END, { features: { sso: false } }, engine); + expect(wiring).toEqual(NOTHING_WIRED); + expect(reads).toEqual([]); + }); + + it('an absent public config reads as NOTHING configured — the loud default', async () => { + const { engine } = engineOver({ ssoProviders: [{ id: 'ssop_1' }] }); + await expect(probeSignInPathWiring(DEAD_END, undefined, engine)).resolves.toEqual(NOTHING_WIRED); + }); +}); diff --git a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts index 3b39367002..f6bb03fb24 100644 --- a/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts +++ b/packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts @@ -132,11 +132,68 @@ * were SEEN and accounts were SEEN ABSENT. Every other shape, `unknown` * included, is silent — an absence of measurement is not evidence of a dead * end. + * + * ## [#15074] Why a DELEGATED sign-in path silences this report + * + * The two store facts above are not, by themselves, the predicate this report + * stands in for. They were written for a deployment whose ONLY way in is a + * `sys_account` row; on a deployment whose sign-in is DELEGATED to an identity + * provider they describe the healthy resting state instead, and the message's + * claim — "NOBODY CAN SIGN IN … CANNOT BE RECOVERED FROM INSIDE" — is false + * for it. `AuthConfigSchema.ssoOnlyMode` says so in the contract, naming the + * `cloud-as-IdP` case explicitly: + * + * > managed (IdP-provisioned) users simply hold no local credential + * + * Measured on the card: a cloud tenant environment (`platform_sso_enabled`) + * whose people are provisioned by the control plane and authenticated through + * the platform SSO handoff logged this at `error` on EVERY kernel boot — + * including a boot that had just served a successful sign-in. A recurring + * `error` that is false for a whole deployment class is the failure mode + * AGENTS.md → "Degradation log levels" names: it trains operators to skim + * `error`. + * + * So the report takes a THIRD fact — {@link SignInPathWiring}, resolved from + * the live runtime by {@link probeSignInPathWiring} — and fires only when this + * deployment has no delegated sign-in path either. Three configurations count, + * and each is a way in that needs no operator-written `sys_account` row: + * + * - **`ssoOnlyMode`** (`OS_AUTH_SSO_ONLY` / config, advertised as + * `features.ssoEnforced`) — the deployment DECLARING that its humans sign + * in through an IdP and hold no local credential. Generic over the IdP, + * which is why a platform-SSO tenant kernel is sure to carry it; + * - **a configured social / OIDC provider** — its credentials are in the + * config, so the path is wired and usable, and a human's account row is + * written at their FIRST sign-in rather than by provisioning; + * - **enterprise SSO with at least one registered IdP** — `plugins.sso` / + * `OS_SSO_ENABLED` AND a `sys_sso_provider` row + * ({@link probeSsoProvidersPresence}). SCIM-provisioned people with an IdP + * behind them are exactly this population. + * + * ⛔ **The SELF-HOSTED direction is NOT silenced.** A deployment with humans, + * zero accounts and no delegated path is still the unrecoverable dead end + * #14353 / #14495 describe, and still reports at `error`. So is one that merely + * SWITCHED THE SSO PLUGIN ON with no IdP registered: that route signs nobody + * in, which is why the gate asks for a provider ROW rather than for the flag — + * and why #14353's `FEDERATED SIGN-IN IS WIRED` independence pin keeps its + * meaning unchanged. Silencing both shapes at once is the specific failure this + * card was warned against. + * + * ⛔ **The probe is untouched.** {@link probeSignInAccountsPresence} still asks + * only whether ANY `sys_account` row exists — tightening it to judge whether a + * row is USABLE is #15718's half, it re-decides three pinned #14353 + * behaviours, and it carries an unruled maintainer question. This card gates + * the REPORT on a fact the probe never had; it does not move the probe. + * + * When the gate suppresses the report the shape is still recorded — at `debug`, + * under the same grep token, naming which configuration answered for it — so + * "why is this deployment quiet" has an answer in the log and not only here. */ import { SystemObjectName } from '@objectstack/spec/system'; import { isHumanUserRow } from './audience-posture.js'; import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; +import { SSO_PROVIDER_OBJECT } from './sso-client-secret.js'; /** * The stable NAME of this report — the grep token an operator or a support @@ -246,6 +303,130 @@ export async function probeSignInReachability( return { humanUsers, signInAccounts: await probeSignInAccountsPresence(engine) }; } +/** + * [#15074] The ONE store shape this report speaks about: humans SEEN, accounts + * SEEN ABSENT. Spelled once and read by everything that needs it — the + * predicate below, and the wiring resolver that must not pay for a probe on a + * boot that could never report. + */ +function isNoSignInAccountShape(facts: SignInReachabilityFacts): boolean { + return facts.humanUsers === 'present' && facts.signInAccounts === 'absent'; +} + +/** + * [#15074] Whether at least one enterprise-SSO identity provider is REGISTERED + * (`sys_sso_provider`) — the difference between an SSO route that is mounted + * and one a human could actually sign in through. + * + * Bounded to one row and shaped like its two siblings: never throws, and an + * unanswerable read is `'unknown'`, which the gate reads as "no delegated path + * proven" so an unreadable store keeps the report LOUD rather than quiet. + */ +export async function probeSsoProvidersPresence( + engine: BootProbeEngine | undefined, +): Promise { + if (!usable(engine)) return 'unknown'; + try { + const rows = await engine.find(SSO_PROVIDER_OBJECT, { limit: 1 }, SYSTEM); + return rows.length > 0 ? 'present' : 'absent'; + } catch { + return 'unknown'; + } +} + +/** + * [#15074] What this deployment has configured that can sign a human in + * WITHOUT an operator-written `sys_account` row. + * + * Every member is a WIRING fact, resolved from the live runtime rather than + * from the store's contents — the same split the neighbouring + * `VerificationPathWiring` makes next door. `false` everywhere is the shape + * #14353 was written for: the only way into that deployment is a credential + * row, and there are none. + */ +export interface SignInPathWiring { + /** + * `ssoOnlyMode` — `OS_AUTH_SSO_ONLY` or the config key, advertised as + * `features.ssoEnforced`. The deployment declaring IdP-only sign-in, which + * per its own contract means its managed users hold no local credential. + * Generic over the IdP: cloud-as-IdP (platform SSO) included. + */ + ssoOnlyMode: boolean; + /** At least one social or OIDC provider is configured (credentials present). */ + socialSignIn: boolean; + /** Enterprise SSO is wired AND at least one `sys_sso_provider` row exists. */ + enterpriseSso: boolean; +} + +/** + * [#15074] The subset of `AuthManager.getPublicConfig()` this gate reads. + * Declared structurally so the caller can hand the public config straight over, + * and so nothing here depends on the rest of that response. + */ +export interface SignInPathConfigView { + socialProviders?: unknown[]; + features?: { sso?: boolean; ssoEnforced?: boolean }; +} + +/** + * [#15074] Resolve the wiring facts for a boot, paying for the provider probe + * only when the answer can change what is reported. + * + * Two short-circuits, both deliberate: a boot that is not in the dead-end shape + * cannot report whatever the wiring says, and a deployment that already has a + * delegated path proven from config needs no store read to confirm a second + * one. Every other boot pays exactly one bounded row read, and only when + * enterprise SSO is switched on. + */ +export async function probeSignInPathWiring( + facts: SignInReachabilityFacts, + config: SignInPathConfigView | undefined, + engine: BootProbeEngine | undefined, +): Promise { + const ssoOnlyMode = config?.features?.ssoEnforced === true; + const socialSignIn = (config?.socialProviders?.length ?? 0) > 0; + const needsProviderProbe = + config?.features?.sso === true && + !ssoOnlyMode && + !socialSignIn && + isNoSignInAccountShape(facts); + const enterpriseSso = needsProviderProbe + ? (await probeSsoProvidersPresence(engine)) === 'present' + : false; + return { ssoOnlyMode, socialSignIn, enterpriseSso }; +} + +/** + * [#15074] The gate itself: the REASON this deployment has a sign-in path that + * needs no `sys_account` row, or `null` when it has none. + * + * A reason rather than a boolean because the suppressed report is still + * recorded at `debug`, and "quiet because something is configured" is only + * useful to the operator if the line says WHICH thing. + */ +export function resolveDelegatedSignInPath(wiring?: SignInPathWiring): string | null { + if (!wiring) return null; + if (wiring.ssoOnlyMode) { + return ( + "SSO-only sign-in is declared for this deployment (ssoOnlyMode / OS_AUTH_SSO_ONLY), so its " + + `humans are provisioned by an identity provider and hold no '${SystemObjectName.ACCOUNT}' row` + ); + } + if (wiring.socialSignIn) { + return ( + 'a social/OIDC sign-in provider is configured, so a human signs in through it and their ' + + `'${SystemObjectName.ACCOUNT}' row is written at that first sign-in` + ); + } + if (wiring.enterpriseSso) { + return ( + `enterprise SSO is wired and at least one '${SSO_PROVIDER_OBJECT}' identity provider is ` + + 'registered, so a human signs in through it without holding a credential row here' + ); + } + return null; +} + /** * The predicate and its message, with no I/O — the whole decision, testable * fact by fact. @@ -262,10 +443,19 @@ export async function probeSignInReachability( * - **`unknown` on either fact** — the store was not consulted (no engine, * a probe failure). See the module doc: at `error` level this report makes * a positive claim or none at all. + * - **[#15074] a DELEGATED sign-in path is configured** — the deployment + * signs its humans in through an identity provider, so "humans, zero + * accounts" is its healthy resting state and not a dead end. Omitting + * `wiring` answers as if nothing were configured, which keeps every + * pre-#15074 caller (and the self-hosted deployment they describe) loud. */ -export function resolveNoSignInAccountReport(facts: SignInReachabilityFacts): string | null { +export function resolveNoSignInAccountReport( + facts: SignInReachabilityFacts, + wiring?: SignInPathWiring, +): string | null { if (facts.humanUsers !== 'present') return null; if (facts.signInAccounts !== 'absent') return null; + if (resolveDelegatedSignInPath(wiring)) return null; return ( `[auth] ${NO_SIGN_IN_ACCOUNT_AT_BOOT}: this deployment has human '${SystemObjectName.USER}' rows ` + @@ -323,6 +513,13 @@ export function resolveNoSignInAccountReport(facts: SignInReachabilityFacts): st export interface BootDiagnosticLogger { error?(message: string, ...rest: unknown[]): void; warn(message: string, ...rest: unknown[]): void; + /** + * [#15074] Where the SUPPRESSED shape goes. Optional because it carries no + * guarantee and needs none: a sink without it simply records nothing, and the + * only thing lost is a trace line. ⛔ Never a channel this report DEGRADES to + * — a real dead end is `error` with the `warn` fallback above, never `debug`. + */ + debug?(message: string, ...rest: unknown[]): void; } /** @@ -338,14 +535,34 @@ export interface BootDiagnosticLogger { export function reportIfNoSignInAccountExists( facts: SignInReachabilityFacts, logger?: BootDiagnosticLogger, + wiring?: SignInPathWiring, ): string | null { let message: string | null = null; try { - message = resolveNoSignInAccountReport(facts); + message = resolveNoSignInAccountReport(facts, wiring); } catch { return null; } - if (!message) return null; + if (!message) { + // [#15074] The one `null` that is worth a line: the dead-end SHAPE is here, + // and the only reason it is not a dead end is something this deployment has + // configured. Recorded at `debug` under the same grep token so the question + // "why is this quiet" is answerable from the log. Every other `null` — + // accounts present, no humans, an unanswered probe — stays fully silent. + try { + const delegated = isNoSignInAccountShape(facts) ? resolveDelegatedSignInPath(wiring) : null; + if (delegated) { + logger?.debug?.( + `[auth] ${NO_SIGN_IN_ACCOUNT_AT_BOOT}: NOT REPORTED — this deployment has human ` + + `'${SystemObjectName.USER}' rows and ZERO '${SystemObjectName.ACCOUNT}' rows, which is ` + + `the NORMAL state here and not a dead end, because ${delegated}.`, + ); + } + } catch { + /* a logger that throws must not abort the boot */ + } + return null; + } try { // An `error?.(…)` against a sink without `error` emits NOTHING, so the // `warn` fallback is an explicit branch rather than an optional call.