From d5eeda5e408cd10391e48f7a1613c39c7d640f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:54:00 +0000 Subject: [PATCH] feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; platformAdmin audit service; legacy-grant deprecation pointer Under walled postures bootstrapPlatformAdmin writes no sys_user_permission_set row any more - standing is config-derived at the one derivation site (resolve-authz-context 6b-config). single keeps first-user promotion and its grant row byte-for-byte (Choice 4A). The walled branch now reports: a per-boot standing log (one implementation with the new read-only platformAdmin service: configuredEmails() + standing()), and a once-per-process deprecation pointer for a detected legacy org-less grant, through the same latch the derivation- site reporter uses, naming OS_PLATFORM_OWNER_EMAIL and the holder. shouldReplayBootstrapFor narrows with the retired elevation: sys_user insert/create under non-walled postures only - the #11343 update arm existed solely to re-attempt the walled elevation after the verifying write, and under walled postures no sys_user write can change the bootstrap's answer at all. The local isEmailVerified alias is gone; the shared predicates (isEmailVerifiedUserRow, normalizePlatformAdminEmail) are consumed directly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .changeset/walled-bootstrap-stops-granting.md | 46 ++ ...tstrap-platform-admin-walled-owner.test.ts | 479 +++++++++--------- .../src/bootstrap-platform-admin.ts | 383 ++++++-------- .../src/explain-engine.test.ts | 30 +- packages/plugins/plugin-security/src/index.ts | 13 + .../src/platform-admin-service.test.ts | 170 +++++++ .../src/platform-admin-service.ts | 164 ++++++ .../src/platform-owner-wall-bypass.ts | 14 +- .../src/security-plugin.test.ts | 33 +- .../plugin-security/src/security-plugin.ts | 41 +- 10 files changed, 875 insertions(+), 498 deletions(-) create mode 100644 .changeset/walled-bootstrap-stops-granting.md create mode 100644 packages/plugins/plugin-security/src/platform-admin-service.test.ts create mode 100644 packages/plugins/plugin-security/src/platform-admin-service.ts diff --git a/.changeset/walled-bootstrap-stops-granting.md b/.changeset/walled-bootstrap-stops-granting.md new file mode 100644 index 0000000000..2937f94bf5 --- /dev/null +++ b/.changeset/walled-bootstrap-stops-granting.md @@ -0,0 +1,46 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; read-only `platformAdmin` audit service; legacy-grant deprecation pointer (#11974, #11663 L4) + +Under **walled postures** (`group` / `isolated`), `bootstrapPlatformAdmin` no +longer writes the org-less `sys_user_permission_set` row pointing at +`admin_full_access`. Platform-admin standing on those deployments is +**config-derived** at the one derivation site (`resolve-authz-context.ts` +§6b-config, landed with #11663 L2): every account whose stored `sys_user` row +holds a declared `OS_PLATFORM_OWNER_EMAIL` address and reads VERIFIED resolves +`PLATFORM_ADMIN` at request time — nothing to mint, nothing to revoke, no +window in which a row grants standing that policy would refuse. The `single` +posture keeps first-user promotion and its grant row byte-for-byte (#11663 +Choice 4A; 4B is the sequenced follow-up). + +What the walled bootstrap still does: + +- **Reports standing** — one info line per boot listing, per declared + address: registered? verified? which account holds standing. The same + implementation serves the new read-only **`platformAdmin` service** + (`configuredEmails()` + `standing()`, registered by SecurityPlugin), so the + log and the audit surface can never disagree. The service is frozen and has + no writable member — there is deliberately no runtime path that changes who + a platform administrator is (#11663 Choice 3A). +- **Points legacy grants at the config path** — a detected legacy org-less + human grant logs exactly one deprecation line per process (shared latch + with the derivation-site reporter) naming `OS_PLATFORM_OWNER_EMAIL`, the + holder and the config line that re-anchors them. Nothing is revoked: the + legacy row still confers during the loud, time-boxed migration window + (#11663 P5). + +The bootstrap-replay trigger (`shouldReplayBootstrapFor`) narrows with the +retired elevation: it now fires only for `sys_user` insert/create under +non-walled postures (the `single` first-user promotion). The #11343 update arm +(`email_verified` / `email`) existed solely to re-attempt the walled elevation +after the owner's verifying write; with standing derived at request time there +is nothing to re-attempt, and under walled postures no `sys_user` write can +change the bootstrap's answer at all. + +Walled bootstrap outcomes: a declared usable config now answers +`reason: 'walled_config_derived'` (replacing `walled_owner_not_registered` / +`walled_owner_not_verified`, whose distinctions moved into the standing +report); `walled_owner_email_undeclared` stays for the unset/blank/refused +backstop (Choice 2B: one unparseable entry fails the whole variable closed). diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts index 2391839e1a..33f267aee5 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -1,47 +1,49 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * bootstrapPlatformAdmin — posture-keyed elevation (#11184, the framework leg - * of cloud#1509; maintainer ruling 2026-08-23, verbatim: - * 「1509 选择 env 指定 owner 邮箱」). + * bootstrapPlatformAdmin — posture-keyed platform-admin answer. * - * Measured defect: on a walled deployment (`OS_TENANCY_POSTURE=isolated` + - * invite-only) the FIRST self-registrant received the cross-tenant - * `admin_full_access` grant — and, because `ensureDefaultOrganization` binds - * "the platform admin", the operator's Default Organization too. + * History of this surface, because the pins below flip an older family: + * - #11184 (framework leg of cloud#1509): walled postures stopped promoting + * the first registrant; only the env-declared owner elevated. + * - #11343: the walled match additionally required a VERIFIED email. + * - #13147: `OS_PLATFORM_OWNER_EMAIL` became a comma-separated list through + * the ONE parser in `@objectstack/core`. + * - **#11974 (#11663 L4, maintainer acceptance 2026-08-25, Choice 4A/5A): + * the walled ELEVATION is retired.** Under walled postures the bootstrap + * writes NO `sys_user_permission_set` row at all — standing is + * config-derived at the one derivation site (`resolve-authz-context.ts` + * §6b-config, pinned in core's + * `resolve-authz-context.platform-admin-config.test.ts`). What this + * function still owns under walled postures is REPORTING: the per-entry + * standing log (same implementation as the read-only `platformAdmin` + * service) and the once-per-process legacy-grant deprecation pointer + * (pin #5 — loud migration, never a silent dual-track). * - * Both directions are pinned here: - * (a) walled: ONLY the account matching the env-declared owner email - * (`OS_PLATFORM_OWNER_EMAIL`) elevates — a self-registrant never does, - * whatever the arrival order; undeclared owner ⇒ the elevation REFUSES - * (it never falls back to first-registrant), loudly, naming the variable; - * (b) single: "first user is owner" is ruled reasonable and UNCHANGED — the - * owner-email variable is never consulted there. + * Both directions stay pinned: walled writes NOTHING whatever the account + * state, and `single` keeps first-user promotion byte-for-byte (Choice 4A — + * the over-denial guard: retiring the walled write must not retire the + * `single` one). * - * [#11343] The walled match must additionally be VERIFIED: an email string is - * not identity, so an account holding the owner's address with - * `email_verified` unset/false is refused (`walled_owner_not_verified`). - * BOTH directions of that invariant are pinned below — the unverified holder - * is refused AND the verified owner is elevated (including across the - * refuse-then-verify-then-re-run sequence the bootstrap-replay middleware - * drives; its trigger set, `shouldReplayBootstrapFor`, is pinned here - * beside it). A suite pinning only the refusal would score green on a - * platform nobody can administer. - * - * The refusals here are bootstrap outcomes, not HTTP answers, so there is no + * The outcomes here are bootstrap returns, not HTTP answers, so there is no * ADR-0112 envelope to assert; the machine-checkable surface is the exact - * `reason` value plus the absence of any `sys_user_permission_set` write (the - * "service was never called" half). + * `reason` value plus the absence of any `sys_user_permission_set` write. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { + resetLegacyPlatformAdminGrantReport, + setPlatformAdminConfigSink, + type PlatformAdminConfigSink, +} from '@objectstack/core'; +import { SystemUserId } from '@objectstack/spec/system'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; -/** In-memory ql over the three objects the promotion path touches. */ -function makeQl(seed: { users?: any[]; grants?: any[] } = {}) { +/** In-memory ql over the three objects the bootstrap touches. */ +function makeQl(seed: { users?: any[]; grants?: any[]; sets?: any[] } = {}) { const tables = new Map([ - ['sys_permission_set', []], + ['sys_permission_set', (seed.sets ?? []).map((r) => ({ ...r }))], ['sys_user', (seed.users ?? []).map((r) => ({ ...r }))], ['sys_user_permission_set', (seed.grants ?? []).map((r) => ({ ...r }))], ]); @@ -84,11 +86,9 @@ const adminFullAccess = () => const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); -/** - * [#11343] Rows carry `email_verified` explicitly where the case under test - * depends on it. A row WITHOUT the field models an imported/legacy account — - * which the elevation predicate deliberately reads as UNVERIFIED. - */ +/** Rows carry `email_verified` explicitly where the case under test depends on + * it. A row WITHOUT the field models an imported/legacy account — which the + * shared predicate deliberately reads as UNVERIFIED. */ const user = (id: string, email: string, createdAt: string, extra: Record = {}) => ({ id, email, @@ -96,6 +96,9 @@ const user = (id: string, email: string, createdAt: string, extra: Record) => + log.info.mock.calls.map((c) => String(c[0])).join('\n'); + const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; @@ -115,152 +118,146 @@ afterEach(() => { }); // ─────────────────────────────────────────────────────────────────────────── -describe('walled posture + declared owner — only the owner elevates', () => { - it('promotes the declared owner even when a self-registrant arrived FIRST', async () => { +// [#11974] The walled grant write is RETIRED — no account state mints a row. +// ─────────────────────────────────────────────────────────────────────────── +describe('walled posture — no grant row is EVER written (#11974 / #11663 L4)', () => { + it('a declared, registered, VERIFIED owner gets NO row — standing is config-derived, and the log says so', async () => { process.env.OS_TENANCY_POSTURE = 'isolated'; process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const log = logger(); const ql = makeQl({ users: [ user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z'), - // [#11343] The owner fixture is VERIFIED — this pin is about arrival - // order, and it must keep holding under the verified-email invariant. user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true }), ], }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(true); - const grants = ql.grants(); - expect(grants).toHaveLength(1); - // The cross-tenant grant lands on the OWNER — never the first registrant. - expect(grants[0].user_id).toBe('u_owner'); - expect(grants[0].organization_id).toBeNull(); - }); - - it('matches the owner email case-insensitively (declared spelling ≠ stored spelling)', async () => { - process.env.OS_TENANCY_POSTURE = 'isolated'; - process.env.OS_PLATFORM_OWNER_EMAIL = 'Operator@Corp.EXAMPLE'; - const ql = makeQl({ - // [#11343] Verified — this pin is about case-insensitive matching, and - // it must keep holding under the verified-email invariant. - users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true })], - }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(true); - expect(ql.grants()[0]?.user_id).toBe('u_owner'); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_config_derived'); + // The acceptance pin: the walled bootstrap mints NO org-less grant. + expect(ql.grants()).toHaveLength(0); + // The operator's first sight of the answer — per-entry standing. + const info = infoText(log); + expect(info).toContain('CONFIG-DERIVED'); + expect(info).toContain('operator@corp.example: registered + verified (u_owner)'); }); - // ───────────────────────────────────────────────────────────────────────── - // [#13147] `OS_PLATFORM_OWNER_EMAIL` takes a comma-separated LIST (#11663 - // Choice 2B). Before this card the gate held the operator's whole value as - // ONE address: a list matched no account, so it logged "will be promoted when - // that account registers" forever and the deployment never got its platform - // admin. Fail-closed, and silent — the shape the card exists to close. - it('[#13147] a comma-separated list promotes a declared member, whichever one holds an account', async () => { + it('[#13147] a comma-separated list mints no row for ANY declared member; standing reports each entry', async () => { process.env.OS_TENANCY_POSTURE = 'isolated'; process.env.OS_PLATFORM_OWNER_EMAIL = 'first@corp.example, second@corp.example'; - const ql = makeQl({ - users: [ - user('u_stranger', 'stranger@evil.example', '2026-08-29T01:00:00Z'), - // Only the SECOND declared administrator has registered. - user('u_two', 'second@corp.example', '2026-08-29T02:00:00Z', { email_verified: true }), - ], - }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(true); - expect(ql.grants()).toHaveLength(1); - expect(ql.grants()[0].user_id).toBe('u_two'); - }); - - it('[#13147] list entries keep their case-insensitive match, per entry', async () => { - process.env.OS_TENANCY_POSTURE = 'isolated'; - process.env.OS_PLATFORM_OWNER_EMAIL = 'First@Corp.EXAMPLE , Second@Corp.Example'; + const log = logger(); const ql = makeQl({ users: [user('u_two', 'second@corp.example', '2026-08-29T02:00:00Z', { email_verified: true })], }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(true); - expect(ql.grants()[0]?.user_id).toBe('u_two'); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.grants()).toHaveLength(0); + const info = infoText(log); + expect(info).toContain('first@corp.example: not registered yet'); + expect(info).toContain('second@corp.example: registered + verified (u_two)'); }); - it('[#13147] ONE grant row even when several declared administrators are verified', async () => { - // Deliberately unchanged, not widened: the row written here is the LEGACY - // `admin_full_access` anchor #11663 is retiring. Standing for every - // declared+verified administrator comes from the configuration anchor in - // the one derivation site, so a row per list member would only add rows to - // retire. The oldest wins, exactly as with a single declared address. + it('owner not registered: no row, and the standing log reports it (formerly walled_owner_not_registered)', async () => { process.env.OS_TENANCY_POSTURE = 'isolated'; - process.env.OS_PLATFORM_OWNER_EMAIL = 'first@corp.example,second@corp.example'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const log = logger(); const ql = makeQl({ - users: [ - user('u_two', 'second@corp.example', '2026-08-29T02:00:00Z', { email_verified: true }), - user('u_one', 'first@corp.example', '2026-08-29T01:00:00Z', { email_verified: true }), - ], + users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(true); - expect(ql.grants()).toHaveLength(1); - expect(ql.grants()[0].user_id).toBe('u_one'); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.grants()).toHaveLength(0); + expect(infoText(log)).toContain('operator@corp.example: not registered yet'); }); - it('[#13147] ⛔ a REFUSED list declares nobody — the elevation is refused, not narrowed', async () => { - // Choice 2B: one unparseable entry fails the WHOLE variable closed. The - // valid entry must NOT be promoted — a silently narrower administrator set - // is the outcome the refusal exists to prevent. + it('owner registered but NOT verified: no row, and the standing log names the missing verification', async () => { + // The unverified holder confers nothing at the derivation site either — + // that half is pinned in core. Here: the bootstrap neither writes a row + // for it (old bug direction) nor claims it holds standing (new surface). process.env.OS_TENANCY_POSTURE = 'isolated'; - process.env.OS_PLATFORM_OWNER_EMAIL = 'first@corp.example,not-an-email'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const log = logger(); const ql = makeQl({ - users: [user('u_one', 'first@corp.example', '2026-08-29T01:00:00Z', { email_verified: true })], + users: [user('u_squatter', 'operator@corp.example', '2026-08-23T01:00:00Z', { email_verified: false })], }); - const log = logger(); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); expect(r.adminPromoted).toBe(false); - expect(r.reason).toBe('walled_owner_email_undeclared'); + expect(r.reason).toBe('walled_config_derived'); expect(ql.grants()).toHaveLength(0); - expect(String(log.error.mock.calls[0]?.[0] ?? '')).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect(infoText(log)).toContain('operator@corp.example: registered, NOT verified'); }); - it('[#13147] ⛔ a stranger is never promoted just because a list was declared', async () => { + it('a row WITHOUT the email_verified field (imported/legacy) still reads unverified — absent is never verified', async () => { process.env.OS_TENANCY_POSTURE = 'isolated'; - process.env.OS_PLATFORM_OWNER_EMAIL = 'first@corp.example,second@corp.example'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const log = logger(); const ql = makeQl({ - users: [user('u_stranger', 'stranger@evil.example', '2026-08-29T01:00:00Z', { email_verified: true })], + users: [user('u_legacy', 'operator@corp.example', '2026-08-23T01:00:00Z')], }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(false); - expect(r.reason).toBe('walled_owner_not_registered'); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_config_derived'); expect(ql.grants()).toHaveLength(0); + expect(infoText(log)).toContain('NOT verified'); }); - it('owner not registered yet: refuses with the exact reason and writes NO grant', async () => { + it('the verifying update changes NOTHING here any more: re-running bootstrap after it still writes no row', async () => { + // Pre-#11974 this exact sequence (refuse → verify → re-run) was how the + // owner got elevated, driven by the replay middleware's update arm. The + // sequence is pinned in its NEW meaning: the re-run is a no-write both + // times — the verification's effect happens at request time, in the + // derivation, not here. process.env.OS_TENANCY_POSTURE = 'isolated'; process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; - const log = logger(); const ql = makeQl({ - users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: false })], }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); - expect(r.adminPromoted).toBe(false); - expect(r.reason).toBe('walled_owner_not_registered'); - // The preservation half of the pin: the grant write never happened. + const first = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(first.reason).toBe('walled_config_derived'); + expect(ql.grants()).toHaveLength(0); + + await ql.update('sys_user', { id: 'u_owner', email_verified: true }); + + const second = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(second.adminPromoted).toBe(false); + expect(second.reason).toBe('walled_config_derived'); expect(ql.grants()).toHaveLength(0); }); - it("the `group` posture is walled too — a first registrant that isn't the owner never elevates", async () => { + it("the `group` posture is walled too — same no-write answer", async () => { process.env.OS_TENANCY_POSTURE = 'group'; process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; const ql = makeQl({ - users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], + users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true })], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); expect(r.adminPromoted).toBe(false); - expect(r.reason).toBe('walled_owner_not_registered'); + expect(r.reason).toBe('walled_config_derived'); + expect(ql.grants()).toHaveLength(0); + }); + + it('[#13147] ⛔ a REFUSED list declares nobody — walled_owner_email_undeclared, no row, loud', async () => { + // Choice 2B: one unparseable entry fails the WHOLE variable closed. The + // valid entry must confer nothing — a silently narrower administrator set + // is the outcome the refusal exists to prevent. + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'first@corp.example,not-an-email'; + const ql = makeQl({ + users: [user('u_one', 'first@corp.example', '2026-08-29T01:00:00Z', { email_verified: true })], + }); + const log = logger(); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.adminPromoted).toBe(false); + expect(r.reason).toBe('walled_owner_email_undeclared'); expect(ql.grants()).toHaveLength(0); + expect(String(log.error.mock.calls[0]?.[0] ?? '')).toContain('OS_PLATFORM_OWNER_EMAIL'); }); }); // ─────────────────────────────────────────────────────────────────────────── -describe('walled posture + UNDECLARED owner — fail-closed, never first-registrant', () => { - it('refuses the elevation with the exact reason, logs at error naming the variable, writes NO grant', async () => { +describe('walled posture + UNDECLARED owner — fail-closed backstop (unchanged reason)', () => { + it('answers walled_owner_email_undeclared, logs at error naming the variable, writes NO grant', async () => { process.env.OS_TENANCY_POSTURE = 'isolated'; const log = logger(); const ql = makeQl({ @@ -304,7 +301,84 @@ describe('walled posture + UNDECLARED owner — fail-closed, never first-registr }); // ─────────────────────────────────────────────────────────────────────────── -describe('single posture — "first user is owner" is ruled reasonable and UNCHANGED', () => { +// [#11663 P5] The deprecation pointer for a LEGACY grant — loud migration, +// exactly ONCE per process, through the SAME latch the derivation-site +// reporter uses (boot-time + request-time detection can never total two). +// ─────────────────────────────────────────────────────────────────────────── +describe('walled posture — legacy grant deprecation pointer (#11663 P5)', () => { + let sinkWarns: string[]; + let prevSink: PlatformAdminConfigSink; + + beforeEach(() => { + sinkWarns = []; + resetLegacyPlatformAdminGrantReport(); + prevSink = setPlatformAdminConfigSink({ + error: () => {}, + warn: (m) => sinkWarns.push(m), + }); + }); + afterEach(() => { + setPlatformAdminConfigSink(prevSink); + resetLegacyPlatformAdminGrantReport(); + }); + + /** A legacy DB: the admin set row already exists, and a human holds the + * unscoped grant pointing at it. */ + const legacyDb = () => + makeQl({ + sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], + users: [user('u_legacy', 'legacy-admin@corp.example', '2026-01-01T00:00:00Z', { email_verified: true })], + grants: [{ id: 'ups_1', user_id: 'u_legacy', permission_set_id: 'ps_admin', organization_id: null }], + }); + + it('a seeded legacy grant produces EXACTLY ONE line naming OS_PLATFORM_OWNER_EMAIL — even across repeated bootstraps', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const ql = legacyDb(); + const r1 = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + const r2 = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + // The acceptance pin: exactly one deprecation line, naming the variable, + // the holder and their address (the exact config line to add). + expect(sinkWarns).toHaveLength(1); + expect(sinkWarns[0]).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect(sinkWarns[0]).toContain('u_legacy'); + expect(sinkWarns[0]).toContain('legacy-admin@corp.example'); + // Nothing is revoked and nothing new is minted: the one legacy row stays. + expect(ql.grants()).toHaveLength(1); + expect(r1.reason).toBe('walled_config_derived'); + expect(r2.reason).toBe('walled_config_derived'); + }); + + it('legacy grant + UNDECLARED config: the pointer is the remedy — the undeclared error line is skipped', async () => { + // The deployment HAS an administrator (on the old anchor); yelling "no + // usable administrator" beside the pointer would be false. The reason + // still answers undeclared, truthfully. + process.env.OS_TENANCY_POSTURE = 'isolated'; + const log = logger(); + const ql = legacyDb(); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); + expect(r.reason).toBe('walled_owner_email_undeclared'); + expect(sinkWarns).toHaveLength(1); + expect(sinkWarns[0]).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect(log.error).not.toHaveBeenCalled(); + expect(ql.grants()).toHaveLength(1); + }); + + it('a usr_system-held grant is NOT a legacy holder — no pointer', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const ql = makeQl({ + sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], + grants: [{ id: 'ups_1', user_id: SystemUserId.SYSTEM, permission_set_id: 'ps_admin', organization_id: null }], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + expect(sinkWarns).toHaveLength(0); + expect(r.reason).toBe('walled_config_derived'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('single posture — "first user is owner" is ruled reasonable and UNCHANGED (Choice 4A)', () => { it('promotes the first human user with no owner email declared (the pre-#11184 shape)', async () => { // Posture unset ⇒ `single`. const ql = makeQl({ @@ -319,9 +393,8 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA }); it('never consults the owner-email variable: a declared owner does NOT redirect the single-org promotion', async () => { - // Over-denial guard for direction (b): setting the variable under `single` - // must not change who is promoted — the ruling scoped the owner-email - // bootstrap to walled postures only. + // Over-denial guard for the ruling's direction: setting the variable under + // `single` must not change who is promoted. process.env.OS_TENANCY_POSTURE = 'single'; process.env.OS_PLATFORM_OWNER_EMAIL = 'second@corp.example'; const ql = makeQl({ @@ -335,11 +408,7 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA expect(ql.grants()[0]?.user_id).toBe('u_first'); }); - it('an UNVERIFIED first user is still promoted under `single` — the verified invariant is walled-only', async () => { - // [#11343] Over-denial guard: the ruling restored the invariant on the - // WALLED owner match. `single` posture (the dev/seed-admin flow, where - // verification is typically not wired at all) keeps first-user promotion - // exactly as ruled reasonable in #11184. + it('an UNVERIFIED first user is still promoted under `single` — the verified invariant was walled-only', async () => { const ql = makeQl({ users: [user('u_first', 'first@corp.example', '2026-08-23T01:00:00Z', { email_verified: false })], }); @@ -347,138 +416,52 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA expect(r.adminPromoted).toBe(true); expect(ql.grants()[0]?.user_id).toBe('u_first'); }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#11343] Walled elevation requires the owner-email match to be VERIFIED. -// Both directions on purpose: refusal alone would score green on a platform -// nobody can administer. -// ─────────────────────────────────────────────────────────────────────────── -describe('walled posture — the owner-email match must be VERIFIED (#11343)', () => { - beforeEach(() => { - process.env.OS_TENANCY_POSTURE = 'isolated'; - process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; - }); - it('refuses an account holding the owner email with email_verified:false — the exact sign-up shape — and writes NO grant', async () => { - // The path this card closes: someone registers with the declared owner's - // address before the owner does. better-auth stores `email_verified:false` - // at email/password sign-up, so this row is exactly what that registration - // produces. - const log = logger(); + it('an existing human admin short-circuits as already_have_admin — the single-posture no-op-forever shape', async () => { const ql = makeQl({ - users: [user('u_squatter', 'operator@corp.example', '2026-08-23T01:00:00Z', { email_verified: false })], - }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); - expect(r.adminPromoted).toBe(false); - expect(r.reason).toBe('walled_owner_not_verified'); - expect(ql.grants()).toHaveLength(0); - // Loud, at warn, and the message names the variable and the unblock (verify). - expect(log.warn).toHaveBeenCalledTimes(1); - expect(String(log.warn.mock.calls[0][0])).toContain('OS_PLATFORM_OWNER_EMAIL'); - expect(String(log.warn.mock.calls[0][0])).toContain('NOT VERIFIED'); - }); - - it('a row WITHOUT the email_verified field (imported/legacy) reads as unverified — absent is never verified', async () => { - const ql = makeQl({ - users: [user('u_legacy', 'operator@corp.example', '2026-08-23T01:00:00Z')], + sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], + users: [user('u_admin', 'admin@corp.example', '2026-08-23T01:00:00Z')], + grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: null }], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); expect(r.adminPromoted).toBe(false); - expect(r.reason).toBe('walled_owner_not_verified'); - expect(ql.grants()).toHaveLength(0); - }); - - it('elevates the verified owner — including on the re-run AFTER the verifying update (the exact sequence the replay middleware drives)', async () => { - // First boot: the owner registered but has not clicked the link yet — - // refused, no grant. Then the verification UPDATE lands on the row and the - // bootstrap re-runs (in production: the replay middleware fires on that - // update). Second run: elevated. Pinning the sequence, not just the end - // state, proves the refusal is transient for the genuine owner. - const ql = makeQl({ - users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: false })], - }); - const first = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(first.adminPromoted).toBe(false); - expect(first.reason).toBe('walled_owner_not_verified'); - expect(ql.grants()).toHaveLength(0); - - // The verifying write better-auth issues when the link is clicked. - await ql.update('sys_user', { id: 'u_owner', email_verified: true }); - - const second = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(second.adminPromoted).toBe(true); - const grants = ql.grants(); - expect(grants).toHaveLength(1); - expect(grants[0].user_id).toBe('u_owner'); - expect(grants[0].organization_id).toBeNull(); - }); - - it("accepts a driver's 1 as verified and 0 as unverified (SQLite boolean representation)", async () => { - const refused = makeQl({ - users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: 0 })], - }); - expect((await bootstrapPlatformAdmin(refused as any, [adminFullAccess()], { logger: logger() })).reason).toBe( - 'walled_owner_not_verified', - ); - expect(refused.grants()).toHaveLength(0); - - const elevated = makeQl({ - users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: 1 })], - }); - expect((await bootstrapPlatformAdmin(elevated as any, [adminFullAccess()], { logger: logger() })).adminPromoted).toBe( - true, - ); - expect(elevated.grants()[0]?.user_id).toBe('u_owner'); - }); - - it('two rows hold the owner email: the VERIFIED one is elevated even when the unverified one is older', async () => { - // Arrival order decided ties before #11343; verification outranks it now. - // (Two rows with one email is an imported/legacy shape — sign-up enforces - // uniqueness — but the elevation must still never land on the unverified - // row.) - const ql = makeQl({ - users: [ - user('u_unverified_older', 'operator@corp.example', '2026-08-23T01:00:00Z', { email_verified: false }), - user('u_verified_newer', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true }), - ], - }); - const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); - expect(r.adminPromoted).toBe(true); - const grants = ql.grants(); - expect(grants).toHaveLength(1); - expect(grants[0].user_id).toBe('u_verified_newer'); + expect(r.reason).toBe('already_have_admin'); + expect(ql.grants()).toHaveLength(1); }); }); // ─────────────────────────────────────────────────────────────────────────── -// [#11343] The bootstrap-replay trigger set. Email verification is an UPDATE, -// so an insert-only replay would refuse the unverified owner at sign-up and -// never look again — these pins are the "verified owner IS elevated" half at -// the middleware seam. security-plugin.ts consumes this same predicate. +// [#11974] The bootstrap-replay trigger set, NARROWED with the walled +// elevation's retirement: `single` + create/insert only. The #11343 update +// arm (email / email_verified) fired for the walled verify-then-elevate +// sequence, which no longer exists — and under walled postures NO sys_user +// write can change the bootstrap's answer, so nothing replays at all. +// security-plugin.ts consumes this same predicate. // ─────────────────────────────────────────────────────────────────────────── -describe('shouldReplayBootstrapFor — bootstrap-replay trigger set (#11343)', () => { - it('fires on sys_user insert/create (the original trigger, unchanged)', () => { +describe('shouldReplayBootstrapFor — narrowed trigger set (#11974)', () => { + it('fires on sys_user insert/create under `single` (first-user promotion, unchanged)', () => { expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'insert', data: { email: 'a@b.c' } })).toBe(true); expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'create', data: { email: 'a@b.c' } })).toBe(true); }); - it('fires on a sys_user update touching email_verified — the verifying write', () => { + it('⛔ no longer fires on updates touching email_verified / email — the walled elevation they re-attempted is retired', () => { expect( shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }), - ).toBe(true); - }); - - it('fires on a sys_user update touching email — the change-email write can newly match the declared owner', () => { + ).toBe(false); expect( shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email: 'x@y.z' } }), - ).toBe(true); + ).toBe(false); }); - it('does NOT fire on a sys_user update touching neither elevation column (profile edits must not re-run bootstrap)', () => { - expect( - shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', name: 'New Name' } }), - ).toBe(false); + it('⛔ NEVER fires under a walled posture — no write can change a config-derived answer', () => { + for (const posture of ['isolated', 'group']) { + process.env.OS_TENANCY_POSTURE = posture; + expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'insert', data: { email: 'a@b.c' } })).toBe(false); + expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'create', data: { email: 'a@b.c' } })).toBe(false); + expect( + shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }), + ).toBe(false); + } }); it('does NOT fire for other objects, other operations, or a payload-less update', () => { diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index f670f4b440..e55280c7cd 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -8,23 +8,28 @@ * 1. **Seed `sys_permission_set` rows** for each `defaultPermissionSets` * entry (admin_full_access / member_default / viewer_readonly). * - * 2. **Promote the platform OWNER to platform admin** by inserting a - * `sys_user_permission_set` row that points at `admin_full_access` with - * `organization_id = NULL` (= cross-tenant). If a platform admin already - * exists, this is a no-op forever. WHO the owner is depends on the - * tenancy posture (#11184, maintainer ruling 2026-08-23): - * - `single`: the first registered human user (unchanged); - * - walled (`group`/`isolated`): ONLY the account matching the - * env-declared `OS_PLATFORM_OWNER_EMAIL` — never the first - * registrant, and never anyone at all while that var is undeclared - * (fail-closed; the boot-refusal half lives in plugin-auth `init()`). - * [#11343] The match must additionally be VERIFIED (`email_verified`): - * an unverified account holding the owner's email string is refused - * like any stranger, because with verification off by default the - * string alone proves nothing about who registered it. The verifying - * write is a sys_user UPDATE, so `shouldReplayBootstrapFor` (below) - * gives the replay middleware an update trigger — without it the - * genuine owner would verify and never be elevated at all. + * 2. **Answer the platform-admin question, POSTURE-KEYED** (#11184 ruling + * 2026-08-23; re-anchored by #11663, maintainer acceptance 2026-08-25, + * leg L4 = #11974): + * - `single`: promote the first registered human user by inserting a + * `sys_user_permission_set` row pointing at `admin_full_access` with + * `organization_id = NULL`. Unchanged — Choice 4A keeps first-user + * promotion and its grant row for this posture (4B is the sequenced + * follow-up, not dropped). + * - walled (`group`/`isolated`): **NO grant row is written, ever.** + * Standing is CONFIG-DERIVED at the one derivation site + * (`resolve-authz-context.ts` §6b-config): each account whose stored + * `sys_user` row holds a declared `OS_PLATFORM_OWNER_EMAIL` address + * AND reads VERIFIED resolves PLATFORM_ADMIN at request time. What + * this bootstrap still owns under walled postures is reporting: it + * logs the resolved admin list's standing (the same answer the + * read-only `platformAdmin` service serves — see + * `platform-admin-service.ts`), and points any LEGACY unscoped grant + * holder at the config path via the shared once-per-process + * deprecation reporter (`reportLegacyPlatformAdminGrant`, pin #5: + * loud migration, never a silent dual-track). Undeclared/blank/ + * refused config still refuses loudly (fail-closed backstop; the + * boot-refusal half lives in plugin-auth `init()`). * * The "create a Default Organization for the freshly-promoted admin" * behavior moved to `@objectstack/organizations` (see @@ -60,19 +65,18 @@ import { postureEnforcesWall, type PermissionSet } from '@objectstack/spec/security'; import { SystemUserId } from '@objectstack/spec/system'; +import { PLATFORM_OWNER_EMAIL_ENV, resolveTenancyPosture } from '@objectstack/types'; import { - PLATFORM_OWNER_EMAIL_ENV, - isEmailVerifiedUserRow, - resolveTenancyPosture, -} from '@objectstack/types'; -import { resolvePlatformAdminEmails } from '@objectstack/core'; + reportLegacyPlatformAdminGrant, + resolvePlatformAdminEmails, +} from '@objectstack/core'; import { claimSeedOwnership } from './claim-seed-ownership.js'; import { createSeedWriteRefusals, reportSeedWriteRefusals, type SeedWriteRefusals, } from './per-organization-catalog.js'; -import { matchesDeclaredOwnerEmail } from './platform-owner-wall-bypass.js'; +import { resolvePlatformAdminStanding } from './platform-admin-service.js'; interface BootstrapOptions { /** Logger from PluginContext. */ @@ -155,38 +159,32 @@ function genId(prefix: string): string { } /** - * [#11343] Verified-email predicate for the walled elevation match. - * - * [#12751] The predicate itself moved to `@objectstack/types` - * (`isEmailVerifiedUserRow`) so the owner-verification boot diagnostic in - * `plugin-auth` reads the SAME allow-list this gate refuses on — the - * fail-closed semantics (absent-means-unverified) are documented and pinned - * at the definition. This alias keeps the gate's call sites reading in the - * gate's own vocabulary. - */ -const isEmailVerified = isEmailVerifiedUserRow; - -/** - * [#11343] Which `sys_user` writes can change the answer of the elevation - * query in {@link bootstrapPlatformAdmin} — the trigger predicate for the + * Which `sys_user` writes can change the answer of the promotion in + * {@link bootstrapPlatformAdmin} — the trigger predicate for the * bootstrap-replay middleware in `security-plugin.ts`. Exported so the * middleware and its pins consume the SAME predicate instead of re-deriving * it (the `resolveEngineUpdateDispatch` pattern). * - * - `create` / `insert`: a new account may be the declared owner (walled) or - * the first human user (`single`) — the original re-run trigger, unchanged. - * - `update` whose payload touches `email_verified` or `email`: email - * verification is an UPDATE (better-auth flips `emailVerified` when the - * link is clicked, and change-email writes `{ email, emailVerified: true }` - * — both reach the engine snake_cased via the adapter mapping). A re-run - * bound to insert alone would refuse the unverified owner at sign-up and - * then never look again, so the genuine owner would NEVER be elevated — - * trading the wrong-person-elevated defect for a nobody-can-administer - * one. These two columns are exactly the `sys_user` columns the walled - * owner-match reads. - * - any other operation, or an update touching neither column: cannot change - * the elevation answer — no re-run. The bootstrap is idempotent but not - * free; it must not run on every profile edit. + * [#11974 / #11663 L4] NARROWED with the walled elevation's retirement. The + * #11343 `update` arm (payload touching `email_verified` / `email`) existed + * for exactly one reason: walled elevation was a WRITE that had to be + * re-attempted after the owner's verifying update. Under walled postures the + * bootstrap no longer writes a grant at all — standing is derived from config + * at request time (`resolve-authz-context.ts` §6b-config), so there is + * nothing to re-attempt and NO `sys_user` write can change this function's + * answer. What remains: + * + * - walled (`group`/`isolated`): never replay. Seeding and the standing log + * are `kernel:ready` work; re-running them per sign-up would only re-log + * and re-query. (The REQUESTED posture is read, same fail-stricter + * direction as the bootstrap itself.) + * - `single` + `create`/`insert`: a new account may be the first human user + * — the original first-user-promotion trigger, unchanged (Choice 4A). + * - `single` + any update: could never change the promotion answer — + * `single` promotes the OLDEST human user and never reads + * `email`/`email_verified`. The pre-#11974 update arm fired here for the + * walled match's sake only; with that gone it would be a pure re-run tax + * on every verification write. */ export function shouldReplayBootstrapFor(opCtx: { object?: string; @@ -195,15 +193,8 @@ export function shouldReplayBootstrapFor(opCtx: { }): boolean { if (opCtx?.object !== 'sys_user') return false; const op = opCtx?.operation; - if (op === 'create' || op === 'insert') return true; - if (op === 'update') { - const data = opCtx?.data; - if (!data || typeof data !== 'object') return false; - return ['email_verified', 'email'].some((column) => - Object.prototype.hasOwnProperty.call(data, column), - ); - } - return false; + if (op !== 'create' && op !== 'insert') return false; + return !postureEnforcesWall(resolveTenancyPosture()); } /** @@ -241,8 +232,10 @@ function platformOwnedFields(ps: PermissionSet): Record { } /** - * Persist seed permission sets and promote the first registered user to - * platform admin. Safe to call multiple times. + * Persist seed permission sets and answer the posture-keyed platform-admin + * question: promote the first registered human user under `single`, report + * config-derived standing (and write nothing) under walled postures. Safe to + * call multiple times. */ export async function bootstrapPlatformAdmin( ql: any, @@ -352,7 +345,24 @@ export async function bootstrapPlatformAdmin( // that already has an admin returns `already_have_admin`). const resyncCounts = { resynced, resyncSkipped }; - // 2. First-user platform admin promotion. + // 2. The platform-admin question, POSTURE-KEYED (#11184 ruling 2026-08-23, + // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): + // + // - `single`: first human user is promoted — ruled reasonable, unchanged + // (Choice 4A keeps first-user promotion and its grant row). + // - walled (`group` / `isolated`): NO grant row is written. Standing is + // config-derived at the one derivation site (`resolve-authz-context.ts` + // §6b-config): a stored `sys_user` row holding a declared + // `OS_PLATFORM_OWNER_EMAIL` address AND reading VERIFIED resolves + // PLATFORM_ADMIN at request time. This branch only reports. + // + // The REQUESTED posture (`resolveTenancyPosture()`, what the operator asked + // for) is deliberately the input here rather than the enforced one: a + // deployment that requested a wall must not fall back to first-registrant + // promotion even while running degraded (OS_ALLOW_DEGRADED_TENANCY=1) — + // fail toward the stricter reading, same direction ADR-0093 D5 fails. + const walled = postureEnforcesWall(resolveTenancyPosture()); + const adminPsId = seeded['admin_full_access']; if (!adminPsId) { return { seeded: seededCount, adminPromoted: false, reason: 'admin_permission_set_missing', ...resyncCounts }; @@ -364,72 +374,95 @@ export async function bootstrapPlatformAdmin( { permission_set_id: adminPsId }, 50, ); - // A platform admin "already exists" only if a *human* holds the - // cross-tenant grant. The seed-data owner `usr_system` (provisioned by - // the SeedLoader, see runtime/app-plugin.ts `ensureSeedIdentity`) must - // never count — otherwise a DB where it was wrongly promoted would block - // every real admin forever. Ignoring it here makes the bootstrap - // self-healing on restart. - if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM)) { + // Human holders of the cross-tenant grant. The seed-data owner `usr_system` + // (provisioned by the SeedLoader, see runtime/app-plugin.ts + // `ensureSeedIdentity`) never counts — otherwise a DB where it was wrongly + // promoted would block every real admin forever. Ignoring it here makes the + // bootstrap self-healing on restart. + const humanUnscopedHolders = existingAdminLinks.filter( + (r) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM, + ); + // `single`: a platform admin "already exists" — the promotion is a no-op + // forever. Under walled postures that same row is the LEGACY anchor and gets + // the deprecation pointer below instead of a silent early exit. + if (!walled && humanUnscopedHolders.length > 0) { return { seeded: seededCount, adminPromoted: false, reason: 'already_have_admin', ...resyncCounts }; } - // [#11184 / cloud#1509] Elevation is POSTURE-KEYED (maintainer ruling - // 2026-08-23, verbatim: 「1509 选择 env 指定 owner 邮箱」): - // - // - `single`: first human user is promoted — ruled reasonable, unchanged. - // - walled (`group` / `isolated`): the first-registrant path is REMOVED. - // Platform admin is granted ONLY to the account matching the - // env-declared owner email (`OS_PLATFORM_OWNER_EMAIL`). On a walled - // deployment with self-registration reachable, whoever curls sign-up - // first would otherwise receive the cross-tenant `admin_full_access` - // grant AND (via `ensureDefaultOrganization`, which binds "the platform - // admin") the operator's Default Organization — measured on a real - // walled SaaS in cloud#1509. - // - // The REQUESTED posture (`resolveTenancyPosture()`, what the operator asked - // for) is deliberately the input here rather than the enforced one: a - // deployment that requested a wall must not fall back to first-registrant - // elevation even while running degraded (OS_ALLOW_DEGRADED_TENANCY=1) — - // fail toward the stricter reading, same direction ADR-0093 D5 fails. - // - // The startup half of the fail-closed clause (walled + undeclared owner ⇒ - // REFUSE BOOT, naming the variable) lives in plugin-auth's `init()`, which - // every standard walled composition runs and where a throw aborts the boot. - // This branch is the defense-in-depth backstop for paths that reach the - // bootstrap without that guard (`os meta resync`, embeddings without - // plugin-auth): it refuses the ELEVATION, loudly, and never silently - // reverts to promoting the first registrant. - // - // [#13147] `OS_PLATFORM_OWNER_EMAIL` takes one address OR a comma-separated - // list (#11663 Choice 2B), so this gate reads the PARSED config from the one - // shared parser (`@objectstack/core`) rather than the raw string. Before, it - // held the operator's whole value as if it were a single address: a list - // matched no account at all, and this gate logged "will be promoted when - // that account registers" forever — fail-closed, but the deployment silently - // never got its platform admin. - // - // "Declared" is now `emails.length > 0`, which folds in the third state the - // raw read could not see: a list REFUSED for an unparseable entry yields zero - // administrators, and refusing the elevation is the same correct answer as - // for an unset variable. The parser has already said WHY, loudly and once - // per process, so the refusal below does not have to distinguish them. - const walled = postureEnforcesWall(resolveTenancyPosture()); - const platformAdminConfig = walled ? resolvePlatformAdminEmails() : undefined; - if (walled && platformAdminConfig!.emails.length === 0) { - const message = - `[security] tenancy posture is walled but ${PLATFORM_OWNER_EMAIL_ENV} declares no usable ` + - 'platform administrator (unset, blank, or refused for an unparseable entry) — ' + - 'REFUSING platform-admin elevation. Under walled postures the first registrant is ' + - 'never promoted; platform admin is granted only to an account matching a declared ' + - `owner email. Set ${PLATFORM_OWNER_EMAIL_ENV} to the operator's email address ` + - '(or a comma-separated list of addresses).'; - if (logger?.error) logger.error(message); - else logger?.warn?.(message); + if (walled) { + // [#11974 / #11663 L4, Choice 5A first half] The walled promotion is + // RETIRED: no `sys_user_permission_set` row is minted, whatever accounts + // exist. Nothing is revoked either — an existing legacy grant still + // confers (P5's honoured window, enforced at the derivation site) — but + // it is now the OLD anchor, so its holder is pointed at the config path + // ONCE per process through the same latch the derivation-site reporter + // uses (`reportLegacyPlatformAdminGrant`): boot-time detection here and + // request-time detection there can never add up to two lines. + if (humanUnscopedHolders.length > 0) { + const holder = humanUnscopedHolders[0]; + const holderRows = await tryFind(ql, 'sys_user', { id: holder.user_id }, 1); + reportLegacyPlatformAdminGrant({ + userId: String(holder.user_id), + email: holderRows[0]?.email, + }); + } + + // Fail-closed backstop for an unusable config (unset, blank, or a list + // REFUSED for an unparseable entry — #11663 Choice 2B folds all three + // into `emails.length === 0`; the parser has already said WHY, once per + // process). The startup half (walled + undeclared ⇒ REFUSE BOOT, naming + // the variable) lives in plugin-auth's `init()`; this is the + // defense-in-depth line for paths that reach the bootstrap without that + // guard (`os meta resync`, embeddings without plugin-auth). With a legacy + // holder present the deprecation pointer above already carries the + // remedy, so the extra error line is skipped — the deployment HAS an + // administrator, on the old anchor. + const platformAdminConfig = resolvePlatformAdminEmails(); + if (platformAdminConfig.emails.length === 0) { + if (humanUnscopedHolders.length === 0) { + const message = + `[security] tenancy posture is walled but ${PLATFORM_OWNER_EMAIL_ENV} declares no usable ` + + 'platform administrator (unset, blank, or refused for an unparseable entry) — ' + + 'this deployment has ZERO config-derived platform administrators. Under walled ' + + 'postures the first registrant is never promoted and no grant row is written; ' + + `platform admin standing is derived from ${PLATFORM_OWNER_EMAIL_ENV} at request ` + + "time. Set it to the operator's email address (or a comma-separated list of " + + 'addresses) and make sure the account verifies its email.'; + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + } + return { + seeded: seededCount, + adminPromoted: false, + reason: 'walled_owner_email_undeclared', + ...resyncCounts, + }; + } + + // The operator's first sight of the answer — the SAME answer the + // read-only `platformAdmin` service serves (one implementation, see + // platform-admin-service.ts). Per declared entry: does an account exist, + // is it verified, which account holds standing. + const standing = await resolvePlatformAdminStanding(ql, platformAdminConfig); + const summary = standing + .map((s) => + s.registered + ? s.verified + ? `${s.email}: registered + verified (${s.userId})` + : `${s.email}: registered, NOT verified — no standing until the address verifies` + : `${s.email}: not registered yet`, + ) + .join('; '); + logger?.info?.( + `[security] walled posture — platform-admin standing is CONFIG-DERIVED (${PLATFORM_OWNER_EMAIL_ENV}); ` + + 'no grant row is written. Each declared, VERIFIED account resolves PLATFORM_ADMIN ' + + `at request time. ${summary}`, + { standing: standing.map((s) => ({ ...s })) }, + ); return { seeded: seededCount, adminPromoted: false, - reason: 'walled_owner_email_undeclared', + reason: 'walled_config_derived', ...resyncCounts, }; } @@ -466,94 +499,18 @@ export async function bootstrapPlatformAdmin( return ta - tb; })[0]; - let target: any; - if (walled) { - // Query BY EMAIL rather than scanning the first N users: on a walled - // deployment any number of self-registrants may exist before the owner - // registers, and the owner must be found regardless of arrival order. - // Email comparison is case-insensitive; better-auth stores sign-up emails - // lowercased, but imported/legacy rows may not be, so both the lowercased - // and the verbatim spellings are queried and matches are de-duplicated. - // - // [#13147] Both spellings, now for EVERY declared administrator. The - // as-typed spellings come from the parser's own `declaredSpellings` — ⛔ the - // raw value is never split a second time here, which is the whole point of - // the card: one separator, one normalization, one meaning. - const config = platformAdminConfig!; - const spellings = [...new Set([...config.emails, ...config.declaredSpellings])]; - const byId = new Map(); - for (const spelling of spellings) { - for (const u of await tryFind(ql, 'sys_user', { email: spelling }, 5)) { - if (u?.id) byId.set(u.id, u); - } - } - // [#12974] The email comparison is the SHARED canonical one — the same - // predicate the Layer 0 owner wall bypass keys on (see - // `platform-owner-wall-bypass.ts`, which names this gate as its twin). - const owners = [...byId.values()].filter( - (u) => isHumanUser(u) && matchesDeclaredOwnerEmail(u, config), - ); - if (owners.length === 0) { - logger?.info?.( - `[security] walled posture — platform admin will be granted to a declared owner ` + - `(${PLATFORM_OWNER_EMAIL_ENV}=${config.declaredSpellings.join(', ')}) when that account ` + - `registers; self-registrants are never promoted`, - ); - return { - seeded: seededCount, - adminPromoted: false, - reason: 'walled_owner_not_registered', - ...resyncCounts, - }; - } - // [#11343] The email STRING alone is not identity: with self-registration - // reachable and email verification off by default, anyone who knows the - // declared owner's address and registers before the owner would match here - // and be elevated. Elevation therefore requires the match to be VERIFIED — - // an account row whose `email_verified` better-auth has confirmed (the - // verification link, or a trusted SSO provider at insert). An owner-email - // account that is not verified is refused exactly like a stranger, loudly, - // and never falls back — same fail-closed direction as the undeclared-owner - // refusal above. The refusal is transient for the genuine owner: the - // verifying write is a sys_user UPDATE, and the bootstrap-replay middleware - // (security-plugin.ts, via `shouldReplayBootstrapFor`) re-runs this - // function on exactly that update. - // - // [#13147] With a comma-separated list, several declared administrators may - // hold verified accounts. Exactly ONE grant row is still written, and it is - // the oldest — unchanged, and deliberately not widened to "grant every - // declared owner". The row written here is `admin_full_access`, the LEGACY - // anchor that #11663 is retiring; standing for every declared+verified - // administrator already comes from the configuration anchor in the one - // derivation site (`resolve-authz-context.ts` section 6b-config), so minting - // one legacy row per list member would add nothing but rows to retire. - const verifiedOwners = owners.filter(isEmailVerified); - if (verifiedOwners.length === 0) { - logger?.warn?.( - `[security] walled posture — an account matching a declared owner email ` + - `(${PLATFORM_OWNER_EMAIL_ENV}) exists but its email is NOT VERIFIED; ` + - `REFUSING platform-admin elevation until the owner account verifies its address. ` + - `Unverified accounts are never promoted, whoever registered them. If this ` + - `deployment has no verification path, wire an email transport (or sign the ` + - `owner in through a trusted SSO provider) — elevation will not fall back.`, - ); - return { - seeded: seededCount, - adminPromoted: false, - reason: 'walled_owner_not_verified', - ...resyncCounts, - }; - } - target = oldestOf(verifiedOwners); - } else { - const allUsers = await tryFind(ql, 'sys_user', {}, 50); - const humanUsers = allUsers.filter(isHumanUser); - if (humanUsers.length === 0) { - logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); - return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts }; - } - target = oldestOf(humanUsers); + // [#11974 / #11663 L4] `single` is the ONLY posture that still selects a + // target and writes the grant row (Choice 4A). The walled selection — query + // by declared email, verified-only, oldest wins — moved with the decision + // itself into the derivation site (`resolve-authz-context.ts` §6b-config) + // and, for the audit answer, `platform-admin-service.ts`. + const allUsers = await tryFind(ql, 'sys_user', {}, 50); + const humanUsers = allUsers.filter(isHumanUser); + if (humanUsers.length === 0) { + logger?.info?.('[security] no human users yet — first sign-up will be promoted to platform admin'); + return { seeded: seededCount, adminPromoted: false, reason: 'no_users', ...resyncCounts }; } + const target = oldestOf(humanUsers); const inserted = await tryInsert(ql, 'sys_user_permission_set', { id: genId('ups'), @@ -566,11 +523,7 @@ export async function bootstrapPlatformAdmin( logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`); return { seeded: seededCount, adminPromoted: false, reason: 'insert_failed', ...resyncCounts }; } - logger?.info?.( - walled - ? `[security] a declared platform owner (${PLATFORM_OWNER_EMAIL_ENV}) promoted to platform admin: ${target.email ?? target.id}` - : `[security] first user promoted to platform admin: ${target.email ?? target.id}`, - ); + logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`); // Hand seeded business records (owner_id NULL / usr_system) to the freshly // promoted admin so owner-keyed UX works out of the box. Best-effort and diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 702aace340..98c2f576d0 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -2,7 +2,7 @@ // ADR-0090 D6 — explain engine: layer verdicts, attribution, machine artifact. import { describe, it, expect } from 'vitest'; -import { resolveUserAuthzGrants } from '@objectstack/core'; +import { resolveUserAuthzGrants, resetPlatformAdminEmailMemo } from '@objectstack/core'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { PermissionEvaluator } from './permission-evaluator'; import { explainAccess, buildContextForUser, type ExplainEngineDeps } from './explain-engine'; @@ -694,6 +694,34 @@ describe('buildContextForUser', () => { expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); + it('[#11974 / #11663 L4, P8] CONFIG-derived standing reaches the panel: a declared+verified admin with ZERO grant rows explains as PLATFORM_ADMIN', async () => { + // Under walled postures the bootstrap mints no grant row any more, so this + // is the ONLY shape a fresh walled deployment's administrator has. Explain + // must agree with enforcement about it — and does so structurally, because + // `buildContextForUser` delegates to the resolver whose §6b-config branch + // is the one derivation site (pinned in core's + // `resolve-authz-context.platform-admin-config.test.ts`). This pin holds + // the panel side of that agreement. + const prev = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + resetPlatformAdminEmailMemo(); + try { + const qlConfig = makeGrantQl({ + // No sys_user_permission_set rows at all — the row anchor is absent. + sys_user: [{ id: 'u9', email: 'operator@corp.example', email_verified: true }], + }); + const ctx = await buildContextForUser(qlConfig, 'u9'); + expect(ctx.hasPlatformAdminGrant).toBe(true); + expect(ctx.posture).toBe('PLATFORM_ADMIN'); + expect(ctx.permissions).toContain('admin_full_access'); + expect(ctx.positions).toContain('platform_admin'); + } finally { + if (prev === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = prev; + resetPlatformAdminEmailMemo(); + } + }); + it('a SCOPED (org-specific) admin_full_access user grant does NOT set hasPlatformAdminGrant', async () => { const qlScoped = makeGrantQl({ sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', organization_id: 'org1' }], diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 5297d93d7f..a80726af87 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -31,6 +31,19 @@ export { autoOrgAdminGrantReason, } from './auto-org-admin-grant.js'; export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +// [#11974 / #11663 L4] The read-only platform-admin audit surface (registered +// as the `platformAdmin` service by SecurityPlugin) — config-derived standing +// for the deployment's declared administrators, since walled postures mint no +// grant row to query any more. +export { + createPlatformAdminService, + resolvePlatformAdminStanding, +} from './platform-admin-service.js'; +export type { + PlatformAdminService, + PlatformAdminStandingEntry, + PlatformAdminConfiguredEmails, +} from './platform-admin-service.js'; // [ADR-0105 D8] Scoped-invitation placement — issuance gate + accept-time apply. export { INVITATION_PLACEMENT_SERVICE, diff --git a/packages/plugins/plugin-security/src/platform-admin-service.test.ts b/packages/plugins/plugin-security/src/platform-admin-service.test.ts new file mode 100644 index 0000000000..ae7d811226 --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-admin-service.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * platformAdmin service — the read-only config-derived audit surface + * (#11974 / #11663 L4, pin #3). + * + * With no grant row minted under walled postures, "who are this deployment's + * platform administrators?" is answered from `OS_PLATFORM_OWNER_EMAIL` (the + * ONE parser) plus stored `sys_user` rows. These pins hold the surface to the + * derivation's own semantics: normalized matching, fail-closed verification + * (absent = unverified), refused-list = zero administrators, and NO writable + * member. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { parsePlatformAdminEmails, resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { + createPlatformAdminService, + resolvePlatformAdminStanding, +} from './platform-admin-service.js'; + +function makeQl(users: any[]) { + return { + async find(object: string, q: any) { + if (object !== 'sys_user') return []; + const where = q?.where ?? {}; + const rows = users.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ); + // Hold the caller's bound, by PRESENCE (check:objectql-double-limit). + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }; +} + +const OLD_OWNER = process.env.OS_PLATFORM_OWNER_EMAIL; +beforeEach(() => { + delete process.env.OS_PLATFORM_OWNER_EMAIL; + resetPlatformAdminEmailMemo(); +}); +afterEach(() => { + if (OLD_OWNER === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = OLD_OWNER; + resetPlatformAdminEmailMemo(); +}); + +describe('resolvePlatformAdminStanding — per-entry answer, one implementation for log and service', () => { + it('reports registered + verified with the standing-holding user id', async () => { + const config = parsePlatformAdminEmails('ops@corp.example'); + const ql = makeQl([ + { id: 'u1', email: 'ops@corp.example', email_verified: true, created_at: '2026-01-01T00:00:00Z' }, + ]); + expect(await resolvePlatformAdminStanding(ql, config)).toEqual([ + { + email: 'ops@corp.example', + declaredSpelling: 'ops@corp.example', + registered: true, + verified: true, + userId: 'u1', + }, + ]); + }); + + it('an unverified match is registered but NOT verified, and holds no user id — absent field included', async () => { + const config = parsePlatformAdminEmails('ops@corp.example, second@corp.example'); + const ql = makeQl([ + { id: 'u1', email: 'ops@corp.example', email_verified: false }, + { id: 'u2', email: 'second@corp.example' }, // imported/legacy: no field ⇒ unverified + ]); + const standing = await resolvePlatformAdminStanding(ql, config); + expect(standing).toEqual([ + { email: 'ops@corp.example', declaredSpelling: 'ops@corp.example', registered: true, verified: false }, + { email: 'second@corp.example', declaredSpelling: 'second@corp.example', registered: true, verified: false }, + ]); + }); + + it('finds a row stored in the operator-typed spelling (imported rows are not lowercased; a driver where is exact)', async () => { + const config = parsePlatformAdminEmails('Ops@Corp.Example'); + const ql = makeQl([ + // Stored exactly as typed — only the declaredSpelling query can find it. + { id: 'u1', email: 'Ops@Corp.Example', email_verified: true }, + ]); + const standing = await resolvePlatformAdminStanding(ql, config); + expect(standing).toEqual([ + { + email: 'ops@corp.example', + declaredSpelling: 'Ops@Corp.Example', + registered: true, + verified: true, + userId: 'u1', + }, + ]); + }); + + it('an unregistered entry answers registered:false / verified:false', async () => { + const config = parsePlatformAdminEmails('ops@corp.example'); + expect(await resolvePlatformAdminStanding(makeQl([]), config)).toEqual([ + { email: 'ops@corp.example', declaredSpelling: 'ops@corp.example', registered: false, verified: false }, + ]); + }); + + it('the OLDEST verified account holds standing when several rows match one entry', async () => { + const config = parsePlatformAdminEmails('ops@corp.example'); + const ql = makeQl([ + { id: 'u_newer', email: 'ops@corp.example', email_verified: true, created_at: '2026-02-01T00:00:00Z' }, + { id: 'u_older', email: 'ops@corp.example', email_verified: true, created_at: '2026-01-01T00:00:00Z' }, + ]); + const [entry] = await resolvePlatformAdminStanding(ql, config); + expect(entry!.userId).toBe('u_older'); + }); +}); + +describe('createPlatformAdminService — the registered read-only surface', () => { + it('configuredEmails(): unset ⇒ undeclared, nothing to list', () => { + const svc = createPlatformAdminService(() => makeQl([])); + expect(svc.configuredEmails()).toEqual({ declared: false, refused: false, emails: [] }); + }); + + it('configuredEmails(): blank is undeclared (matches the bootstrap pin)', () => { + process.env.OS_PLATFORM_OWNER_EMAIL = ' '; + resetPlatformAdminEmailMemo(); + const svc = createPlatformAdminService(() => makeQl([])); + expect(svc.configuredEmails()).toEqual({ declared: false, refused: false, emails: [] }); + }); + + it('configuredEmails(): a declared list serves the normalized, de-duplicated addresses', () => { + process.env.OS_PLATFORM_OWNER_EMAIL = ' Ops@Corp.Example , second@corp.example ,ops@corp.example'; + resetPlatformAdminEmailMemo(); + const svc = createPlatformAdminService(() => makeQl([])); + expect(svc.configuredEmails()).toEqual({ + declared: true, + refused: false, + emails: ['ops@corp.example', 'second@corp.example'], + }); + }); + + it('configuredEmails(): a REFUSED list is declared + refused with ZERO administrators (Choice 2B, fail-closed whole)', () => { + process.env.OS_PLATFORM_OWNER_EMAIL = 'ops@corp.example,not-an-email'; + resetPlatformAdminEmailMemo(); + const svc = createPlatformAdminService(() => makeQl([])); + expect(svc.configuredEmails()).toEqual({ declared: true, refused: true, emails: [] }); + }); + + it('standing() serves the same answer as resolvePlatformAdminStanding over the live config', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = 'ops@corp.example'; + resetPlatformAdminEmailMemo(); + const svc = createPlatformAdminService(() => + makeQl([{ id: 'u1', email: 'ops@corp.example', email_verified: true }]), + ); + expect(await svc.standing()).toEqual([ + { email: 'ops@corp.example', declaredSpelling: 'ops@corp.example', registered: true, verified: true, userId: 'u1' }, + ]); + }); + + it('standing() throws LOUDLY when objectql is unavailable — an empty list would read as "no administrators"', async () => { + process.env.OS_PLATFORM_OWNER_EMAIL = 'ops@corp.example'; + resetPlatformAdminEmailMemo(); + const svc = createPlatformAdminService(() => undefined); + await expect(svc.standing()).rejects.toThrow(/objectql service unavailable/); + }); + + it('the service is frozen and exposes NO writable member — there is no runtime path that changes who is an admin', () => { + const svc = createPlatformAdminService(() => makeQl([])); + expect(Object.isFrozen(svc)).toBe(true); + expect(Object.keys(svc).sort()).toEqual(['configuredEmails', 'standing']); + }); +}); diff --git a/packages/plugins/plugin-security/src/platform-admin-service.ts b/packages/plugins/plugin-security/src/platform-admin-service.ts new file mode 100644 index 0000000000..41e0bb61ac --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-admin-service.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * platformAdmin — the read-only platform-administrator AUDIT surface + * (#11663 pin #3, leg L4; design comment 5394453215 §P3). + * + * Under walled postures the bootstrap no longer mints the unscoped + * `admin_full_access` grant row (#11974), so the inverse question — "who are + * this deployment's platform administrators?" — loses its only row-based + * implementation. This service is the config-derived replacement: it answers + * from `OS_PLATFORM_OWNER_EMAIL` (the ONE parser in `@objectstack/core`) plus + * the stored `sys_user` rows, exactly the two inputs the authorization + * derivation reads (`resolve-authz-context.ts` §6b-config). + * + * **Read-only, deliberately and permanently.** There is no runtime path that + * changes who a platform administrator is (#11663 Choice 3A rejected the + * mutation endpoint outright): revocation is a config change + process roll. + * The service object is frozen so a consumer cannot even monkey-patch a + * mutator onto it. + * + * The standing answer mirrors the DERIVATION, not the retired elevation gate: + * rows are matched by the config's own normalization + * (`normalizePlatformAdminEmail`) and verified by the shared fail-closed + * predicate (`isEmailVerifiedUserRow`). No humanness filter is applied — the + * derivation applies none either (a `usr_system` row holding a configured + * address cannot authenticate, but if an operator declares such an address the + * audit surface should SHOW the match rather than quietly hide it). + * + * Consumers: the walled bootstrap's standing log (`bootstrap-platform-admin.ts` + * — the operator's first sight of the answer), and any Setup / discovery / + * health surface that needs the resolved admin list. Both go through + * {@link resolvePlatformAdminStanding} so the log and the service can never + * disagree. + */ + +import { isEmailVerifiedUserRow } from '@objectstack/types'; +import { + normalizePlatformAdminEmail, + resolvePlatformAdminEmails, + type PlatformAdminEmailConfig, +} from '@objectstack/core'; + +const SYSTEM_CTX = { isSystem: true }; + +/** One configured administrator address, resolved against the stored users. */ +export interface PlatformAdminStandingEntry { + /** The normalized address (the derivation's comparison key). */ + email: string; + /** The address as the operator typed it (trimmed, never lowercased). */ + declaredSpelling: string; + /** Does any `sys_user` row hold this address? */ + registered: boolean; + /** + * Does a row holding this address read VERIFIED under the shared fail-closed + * predicate? Only a verified match confers standing at derivation time. + */ + verified: boolean; + /** The oldest VERIFIED matching account — the one that holds standing. */ + userId?: string; +} + +/** The parsed state of the config source, shaped for a read-only audit panel. */ +export interface PlatformAdminConfiguredEmails { + /** Was `OS_PLATFORM_OWNER_EMAIL` set to anything non-blank at all? */ + declared: boolean; + /** + * Declared but REFUSED (an unparseable entry fails the whole variable + * closed — #11663 Choice 2B). `emails` is empty in that case. + */ + refused: boolean; + /** Normalized, de-duplicated administrator addresses, declaration order. */ + emails: readonly string[]; +} + +/** + * The `platformAdmin` service contract — registered by `security-plugin.ts`. + * Read-only by design; see the module header. + */ +export interface PlatformAdminService { + configuredEmails(): PlatformAdminConfiguredEmails; + standing(): Promise; +} + +async function tryFind(ql: any, object: string, where: any, limit: number): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +/** + * Resolve per-entry standing for every configured administrator address. + * + * The lookup queries BOTH the normalized and the as-typed spelling of each + * entry (a driver `where` is an exact match, and an imported/legacy row may + * not be stored lowercased) and then matches by the config's own + * normalization — the same two-spelling discipline the elevation gate used, + * now serving the audit answer instead of a write. + */ +export async function resolvePlatformAdminStanding( + ql: any, + config: PlatformAdminEmailConfig, +): Promise { + const entries: PlatformAdminStandingEntry[] = []; + for (let i = 0; i < config.emails.length; i++) { + const email = config.emails[i]!; + const declaredSpelling = config.declaredSpellings[i] ?? email; + const byId = new Map(); + for (const spelling of new Set([email, declaredSpelling])) { + for (const u of await tryFind(ql, 'sys_user', { email: spelling }, 5)) { + if (u && typeof u === 'object' && u.id) byId.set(String(u.id), u); + } + } + const matching = [...byId.values()].filter( + (u) => normalizePlatformAdminEmail(u.email) === email, + ); + const verified = matching + .filter((u) => isEmailVerifiedUserRow(u)) + .sort((a, b) => { + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + entries.push({ + email, + declaredSpelling, + registered: matching.length > 0, + verified: verified.length > 0, + ...(verified[0]?.id ? { userId: String(verified[0].id) } : {}), + }); + } + return entries; +} + +/** + * Build the frozen read-only service. `getQl` is resolved lazily at call time + * so registration in `init()` does not depend on the engine's start order. + */ +export function createPlatformAdminService(getQl: () => any): PlatformAdminService { + return Object.freeze({ + configuredEmails(): PlatformAdminConfiguredEmails { + const config = resolvePlatformAdminEmails(); + return { + // `raw` is present only when the variable was set to something + // non-blank — blank is undeclared, matching the bootstrap's pin. + declared: config.raw !== undefined, + refused: config.refusal !== undefined, + emails: config.emails, + }; + }, + async standing(): Promise { + const ql = getQl(); + if (!ql || typeof ql.find !== 'function') { + // Loud, not empty: an audit surface answering [] for "engine missing" + // would read as "no administrators", which is a different (and scarier) + // fact than "cannot answer right now". + throw new Error('[security] platformAdmin.standing(): objectql service unavailable'); + } + return resolvePlatformAdminStanding(ql, resolvePlatformAdminEmails()); + }, + }); +} diff --git a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts index 372ef9a421..cbf4fb02c8 100644 --- a/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts +++ b/packages/plugins/plugin-security/src/platform-owner-wall-bypass.ts @@ -5,13 +5,13 @@ * #11343 verified-owner family makes, extracted so its two in-package * consumers can never drift: * - * - **The platform-admin elevation gate** (`bootstrap-platform-admin.ts`, - * the twin this comparison is extracted FROM): its walled arm elevates - * only an account for which BOTH halves below answer yes. It keeps - * consuming the halves separately ({@link matchesDeclaredOwnerEmail} + - * `isEmailVerifiedUserRow`) because its two refusal diagnostics — - * `walled_owner_not_registered` vs `walled_owner_not_verified` — must - * stay distinct. + * - **The platform-admin standing surface** (`platform-admin-service.ts`, + * plus the walled bootstrap's standing log that consumes it): both halves + * below answer its per-entry `registered` / `verified` report. [#11974] + * The walled ELEVATION twin this comparison was extracted from is retired + * — `bootstrap-platform-admin.ts` writes no grant row under walled + * postures; standing is config-derived at the one derivation site + * (`resolve-authz-context.ts` §6b-config), which asks the same parser. * - **The Layer 0 owner wall bypass** (`security-plugin.ts` * `isVerifiedPlatformOwnerSession`, maintainer ruling 2026-08-29 on the * tracking card, verbatim and untranslated: 「能不能简单点,对于超级管理员, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index ab39130b16..f408bc465c 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -100,16 +100,16 @@ describe('SecurityPlugin', () => { }); // ------------------------------------------------------------------------- - // [#11343] Bootstrap-replay wiring — the middleware registered in start() - // re-runs the bootstrap for exactly the writes `shouldReplayBootstrapFor` - // admits. The predicate itself is pinned exhaustively next to its producer + // Bootstrap-replay wiring — the middleware registered in start() re-runs + // the bootstrap for exactly the writes `shouldReplayBootstrapFor` admits. + // The predicate itself is pinned exhaustively next to its producer // (bootstrap-platform-admin-walled-owner.test.ts); THIS pin is that the - // middleware actually consults it — i.e. that a sys_user UPDATE touching - // `email_verified` re-runs the bootstrap. Insert-only replay + the verified - // requirement would strand the genuine owner unelevated forever, so the - // update leg is load-bearing, not an optimization. + // middleware actually consults it. [#11974 / #11663 L4] The trigger set is + // NARROWED: the #11343 update arm (email_verified / email) retired with the + // walled elevation it existed to re-attempt — under `single` (this suite's + // posture) only a sys_user insert/create can change the promotion answer. // ------------------------------------------------------------------------- - it('re-runs the bootstrap on the verifying sys_user update, and not on an unrelated profile edit (#11343)', async () => { + it('re-runs the bootstrap on a sys_user insert, and no longer on the verifying update (#11974 narrowed #11343)', async () => { const plugin = new SecurityPlugin(); const middlewares: any[] = []; const manifestService = { register: vi.fn() }; @@ -170,19 +170,20 @@ describe('SecurityPlugin', () => { } }; - // The verifying write (better-auth flips emailVerified on link click, - // snake_cased by the adapter) ⇒ ONE re-run. + // The formerly-verifying write (better-auth flips emailVerified on link + // click) ⇒ NO re-run any more: the walled elevation it re-attempted is + // retired, and `single` never read this column. await drive({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }); - expect(completions()).toBe(2); + expect(completions()).toBe(1); - // An unrelated profile edit ⇒ NO re-run. + // An unrelated profile edit ⇒ NO re-run (unchanged). await drive({ object: 'sys_user', operation: 'update', data: { id: 'u1', name: 'New Name' } }); - expect(completions()).toBe(2); + expect(completions()).toBe(1); - // The original insert trigger still fires (control that the update leg - // did not narrow the existing behavior). + // The original insert trigger still fires — the over-denial control: the + // narrowing must not retire `single`'s first-user promotion path. await drive({ object: 'sys_user', operation: 'insert', data: { email: 'a@b.c' } }); - expect(completions()).toBe(3); + expect(completions()).toBe(2); }); // [ADR-0105 D2 / #3623] start() hands the engine a posture accessor so the diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2b63620fb5..05fb49a0e2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -113,6 +113,7 @@ import { } from './errors.js'; import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; +import { createPlatformAdminService } from './platform-admin-service.js'; import { backfillOrgAdminGrants, extractMemberPairs, @@ -1008,6 +1009,25 @@ export class SecurityPlugin implements Plugin { // one and falls back to `[fallbackPermissionSet]` on a stack too old to // register it. ctx.registerService('security.baselinePermissionSets', this.baselinePermissionSets); + // [#11974 / #11663 L4, pin #3] The read-only platform-admin AUDIT surface. + // Under walled postures no grant row is minted any more, so "who + // administers this deployment?" loses its row-based answer; this service + // serves the config-derived one (`OS_PLATFORM_OWNER_EMAIL` through the ONE + // parser, plus per-entry sys_user standing) for Setup / discovery / + // health. Registered beside the other security services; deliberately has + // no writable member — there is no runtime path that changes who a + // platform administrator is (#11663 Choice 3A). The engine is resolved + // lazily so registration does not depend on objectql's start order. + ctx.registerService( + 'platformAdmin', + createPlatformAdminService(() => { + try { + return ctx.getService('objectql'); + } catch { + return undefined; + } + }), + ); ctx.getService<{ register(m: any): void }>('manifest').register({ ...securityPluginManifestHeader, @@ -3564,19 +3584,18 @@ export class SecurityPlugin implements Plugin { void runBootstrap(); } - // Re-run bootstrap after a sys_user write that can change the elevation + // Re-run bootstrap after a sys_user write that can change the promotion // answer, so the platform admin is promoted without a server restart: // - // - INSERT: the user that signs up after boot may be the promotion - // target (and, in multi-tenant mode, gets bound to the seeded default - // organization). - // - UPDATE touching `email_verified` / `email` (#11343): under walled - // postures elevation requires the declared owner's email to be - // VERIFIED, and the verifying write is an update (better-auth flips - // `emailVerified` when the link is clicked; change-email rewrites - // both columns). Insert-only replay would refuse the owner at sign-up - // and then never look again — the genuine owner would never be - // elevated at all. + // - `single` posture + INSERT: the user that signs up after boot may be + // the first-user promotion target (and, in multi-tenant mode, gets + // bound to the seeded default organization). + // - [#11974 / #11663 L4] That is the WHOLE trigger set now. Under walled + // postures the bootstrap writes no grant — standing is config-derived + // at request time (`resolve-authz-context.ts` §6b-config) — so no + // `sys_user` write can change its answer and the replay never fires. + // The #11343 UPDATE arm (`email_verified` / `email`) retired with the + // walled elevation it existed to re-attempt. // // The trigger set is `shouldReplayBootstrapFor` — the SAME predicate its // pins consume — and the function itself is idempotent, bailing out as