Skip to content

Commit 2c87a48

Browse files
fix(plugin-auth): gate no_sign_in_account_at_boot on a delegated sign-in path (#17941)
Fixes #15074 Clause-②: no ## What was wrong `no_sign_in_account_at_boot` decides on two store facts — human `sys_user` rows SEEN, `sys_account` rows SEEN ABSENT — and says of that shape: "NOBODY CAN SIGN IN, and the deployment CANNOT BE RECOVERED FROM INSIDE". Those two facts were written for a deployment whose only way in is a credential row. On a deployment whose sign-in is delegated to an identity provider, the same shape is the healthy resting state, and the auth-config contract says so in as many words. `AuthConfigSchema.ssoOnlyMode` (`packages/spec/src/system/auth-config.zod.ts`), naming the cloud-as-IdP case explicitly: > managed (IdP-provisioned) users simply hold no local credential The card measured it on a cloud tenant environment: the ERROR on every kernel boot, including a boot that had just served a successful SSO sign-in, and the only ERROR line in the whole smoke log. ## The gate The report now takes a third fact — `SignInPathWiring`, resolved from the live runtime by `probeSignInPathWiring` — and fires only when this deployment also has no delegated sign-in path. Three configurations count, each a way in that needs no operator-written `sys_account` row: - **`ssoOnlyMode`** (`OS_AUTH_SSO_ONLY` or the config key, advertised as `features.ssoEnforced`) — the deployment declaring IdP-only sign-in. 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, 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. When the gate suppresses the report, the shape is still recorded at `debug` under the same grep token, naming which configuration answered for it — the card's own second option. ### What is deliberately 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. This is also why #14353's `FEDERATED SIGN-IN IS WIRED` independence pin keeps its meaning and stays green, unedited. - An unreadable store answers `unknown`, which the gate reads as "no delegated path proven" — an unreadable store keeps the report loud. - Omitting the new argument entirely answers as if nothing were configured, so no caller can fall quiet by forgetting it. ### Scope fence held - `probeSignInAccountsPresence` keeps its existence-only predicate, byte for byte. Tightening it is #15718's half, it re-decides three pinned #14353 behaviours, and its own open question 2 is unruled. #15718 is not addressed here; it was read as an input only. - The 2026-09-02 admission ruling (option A) carve-out is untouched — no admission semantics move, and boot proceeds in every shape. - `packages/spec` is not touched. The gate is built entirely from facts the runtime already publishes, and the one system object name it needs is the existing `SSO_PROVIDER_OBJECT` constant in `plugin-auth`. ## Evidence ### Reproduced first, then fixed The new suite was written and run **before** the source change, at commit `370ab289`: ``` ❯ src/boot-sign-in-reachability.sso-gate.test.ts (8 tests | 5 failed) 30ms × SSO-only mode via `OS_AUTH_SSO_ONLY` — humans, zero accounts, and NO error 17ms × SSO-only mode declared in CONFIG (`ssoOnlyMode`) reaches the same verdict 2ms × the suppressed report still leaves a `debug` line NAMING the reason 2ms × a configured SOCIAL provider is a sign-in path — no error 2ms × enterprise SSO WITH a registered IdP is a sign-in path — no error 2ms Tests 5 failed | 3 passed (8) ``` The five failures are the false-fire direction (`expected [ Array(1) ] to have a length of +0 but got 1` — the ERROR fired on a platform-SSO-shaped population). The three passes are the controls that keep the no-SSO dead end loud, so the suite was already discriminating before the fix. After the fix, the same file: **26 passed (26)**, both directions. ### #14353's pin suite on the delivered tree `vitest run --reporter=verbose src/boot-sign-in-reachability.test.ts` — **47 passed (47)**, file unedited. The three pins named in the dispatch, plus the independence pin this change had to preserve: ``` ✓ #14353 — the probe reads humans, not rows > ANY `sys_account` row counts — provider, issuer and ban state are not asked about 0ms ✓ #14353 — the report is wired into AuthPlugin boot > NEGATIVE CONTROL — one account exists and the boot is silent 1ms ✓ #14353 — a deployment matching BOTH shapes gets exactly one report > the neighbour is UNTOUCHED when this report did not fire 1ms ✓ #14353 — independent of ALL FOUR walled-owner preconditions > FEDERATED SIGN-IN IS WIRED — the neighbour stays quiet; this still reports 1ms ``` ### Checks | Check | Result | |:---|:---| | `pnpm --filter @objectstack/plugin-auth test` | `Test Files 109 passed (109)` · `Tests 2313 passed (2313)` | | `pnpm --filter @objectstack/plugin-auth typecheck` | exit 0 — `tsc --noEmit`, the examples project, and `check:test-typecheck` ("OK — the test layer compiles") | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0 | | `pnpm --filter '@objectstack/plugin-auth^...' build` and `pnpm --filter @objectstack/plugin-auth build` | exit 0 | | Derived gate family (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 63 commands) | 61 exit 0 | | `pnpm check:dual-build-cjs-loads` | NOT MEASURED — exit 3, `PREREQUISITE NOT MET`: needs a whole-repo `pnpm build` (38 packages have no `dist`). Left to CI. | | `pnpm check:type-check-debt` | NOT MEASURED — exit 3, `PREREQUISITE NOT MET`: `--re-measure` needs the built closure of the ledgered packages. Its sibling `pnpm check:type-check-coverage` ran green. | Both NOT MEASURED rows are the gates' own exit-3 "nothing was measured" code, read from the gate's own verdict line, not from a shell `$?` behind a pipe. ## Clause-② — re-derived from the delivered diff The mechanical floor: minor **iff** the diff adds a new exported symbol reachable from the package's published entry, or a new key on an already-published payload. New exported symbols in the diff, all in `packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts`: `SignInPathWiring`, `SignInPathConfigView`, `probeSsoProvidersPresence`, `probeSignInPathWiring`, `resolveDelegatedSignInPath`. Reachability, measured against the **built** entries rather than read off the source: - `packages/plugins/plugin-auth/src/index.ts` contains no re-export of `./boot-sign-in-reachability.js` (and none of its importers re-export it either); - the package's `exports` map has exactly two entries, `.` and `./rate-limit-storage`. Grepping the built `dist/index.d.ts` and `dist/rate-limit-storage.d.ts` for each of the five new names returns **0** occurrences — as it does for every pre-existing name in that module (`NO_SIGN_IN_ACCOUNT_AT_BOOT`, `probeSignInReachability`, `reportIfNoSignInAccountExists`, …). Positive control on the same grep in the same file: `AuthPlugin`, 30 occurrences. No key was added to any published payload: `SignInReachabilityFacts` — what `probeSignInReachability` returns — is unchanged, `getPublicConfig()` is read but not modified, and no REST response shape moves. The new optional `debug?` member sits on `BootDiagnosticLogger`, which is itself unreachable from both entries. ⇒ **`patch`**, and the changeset is graded `patch`. ## Acceptance notes Noted, not filed: the new probe spells its object through `SSO_PROVIDER_OBJECT` (`plugin-auth/src/sso-client-secret.ts`) because `SystemObjectName` in `packages/spec` carries no member for `sys_sso_provider`, unlike `USER` / `ACCOUNT` / `INVITATION` used a few lines above it. The two spellings sit side by side in this file. It is a naming-consistency observation, not a defect — nothing is unenforced and nothing is wrong at runtime — and closing it would be a `packages/spec` edit, which this lane may not make. --- _Generated by [Claude Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8a006f commit 2c87a48

4 files changed

Lines changed: 700 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Gate the `no_sign_in_account_at_boot` boot report on whether the deployment has a delegated sign-in path.
6+
7+
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.
8+
9+
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.
10+
11+
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`.

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ import {
8282
type WalledOwnerAccountState,
8383
} from './walled-owner-verification-path.js';
8484
import {
85+
probeSignInPathWiring,
8586
probeSignInReachability,
8687
reportIfNoSignInAccountExists,
88+
type SignInPathConfigView,
8789
} from './boot-sign-in-reachability.js';
8890
import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js';
8991
import {
@@ -1002,7 +1004,7 @@ export class AuthPlugin implements Plugin {
10021004
// `AuthManager` without ever registering the kernel `email` service, and
10031005
// the sibling hook below injects the service into it. Reading BOTH makes
10041006
// this hook's answer independent of hook registration order.
1005-
let pub: { socialProviders?: unknown[]; features?: { sso?: boolean } } | undefined;
1007+
let pub: SignInPathConfigView | undefined;
10061008
try { pub = this.authManager?.getPublicConfig(); } catch { pub = undefined; }
10071009
const hasEmailTransport = !!emailSvc || !!this.authManager?.hasEmailTransport();
10081010
const hasFederatedSignIn =
@@ -1025,7 +1027,16 @@ export class AuthPlugin implements Plugin {
10251027
// and the answer handed to the walled-owner probe, so no boot pages
10261028
// `sys_user` twice. Cost on a fresh store is a single bounded page.
10271029
const reachability = await probeSignInReachability(ql);
1028-
const deadEnd = reportIfNoSignInAccountExists(reachability, ctx.logger);
1030+
// [#15074] …and the fact that decides whether "humans, zero accounts" is
1031+
// a dead end AT ALL on this deployment: does it sign people in through an
1032+
// identity provider, which needs no `sys_account` row of its own? On a
1033+
// platform-SSO tenant kernel that population is the HEALTHY one, and the
1034+
// report's "NOBODY CAN SIGN IN" was false on every boot. The resolver
1035+
// pays for its bounded provider read only when the answer can change what
1036+
// is reported; a deployment with no delegated path is untouched and still
1037+
// reports at `error`.
1038+
const signInPath = await probeSignInPathWiring(reachability, pub, ql);
1039+
const deadEnd = reportIfNoSignInAccountExists(reachability, ctx.logger, signInPath);
10291040

10301041
let ownerAccountState: WalledOwnerAccountState = 'unknown';
10311042
if (

0 commit comments

Comments
 (0)