From be6e37144578165a1f7a43abe1020a2fd95cf259 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:43:53 +0000 Subject: [PATCH 1/3] feat(plugin-auth): re-point ensureDefaultOrganization at the config anchor; move its trigger to the sys_user trigger set (L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design #11663 §2 step 5 / H4, ruled bundle 4A. The population question reads the config anchor first (matchesConfiguredPlatformAdmin from @objectstack/core — the derivation site's own predicate, no second derivation site) and falls back to the legacy unscoped admin_full_access grant (Choice 4A single-posture anchor + P5 honoured window, removed with migration step 6). The re-run trigger is the exported isDefaultOrganizationBootstrapTrigger: sys_user insert/create, sys_user update touching email/email_verified (the #11343 trigger set), plus the legacy grant-insert arm unchanged. No guard refusal is added or deleted in this commit — the last-admin-guard re-pricing is its own reviewed step (migration step 5), landing separately. Part of #11973 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .../plugin-auth-default-org-config-anchor.md | 5 + .../plugin-auth/src/auth-plugin.test.ts | 21 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 32 +-- .../src/ensure-default-organization.test.ts | 157 ++++++++++++++- .../src/ensure-default-organization.ts | 190 +++++++++++++++--- 5 files changed, 359 insertions(+), 46 deletions(-) create mode 100644 .changeset/plugin-auth-default-org-config-anchor.md diff --git a/.changeset/plugin-auth-default-org-config-anchor.md b/.changeset/plugin-auth-default-org-config-anchor.md new file mode 100644 index 0000000000..e34778e217 --- /dev/null +++ b/.changeset/plugin-auth-default-org-config-anchor.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-auth': minor +--- + +Re-point the default-organization bootstrap at the platform-admin config anchor (#11973, #11663 leg L3). `ensureDefaultOrganization` now resolves "which user is the platform admin" from `OS_PLATFORM_OWNER_EMAIL` first — the first declared entry with a stored, email-verified `sys_user` account, matched through `@objectstack/core`'s own `matchesConfiguredPlatformAdmin`, the same oracle the authorization derivation reads — and falls back to the legacy unscoped `admin_full_access` grant row (which still anchors `single`-posture deployments and the honoured migration window). Its re-run trigger widens from `sys_user_permission_set` inserts to the new exported predicate `isDefaultOrganizationBootstrapTrigger`: `sys_user` inserts and `email`/`email_verified` updates (how a config-anchored administrator comes into standing — on fresh walled rigs no grant insert ever fires any more), plus the legacy grant-insert arm unchanged. `single`-posture behaviour is unchanged: with the variable unset, the config half costs no read and the grant anchor decides exactly as before. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index f2966de15b..ff434c80f9 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1085,6 +1085,27 @@ describe('AuthPlugin', () => { expect(ql.insert).not.toHaveBeenCalled(); }); + // [#11973 / #11663 L3] The trigger set widened to the #11343 `sys_user` + // arms: a config-anchored administrator comes into standing through a + // `sys_user` insert (operator-provisioned, arrives verified) or a + // verifying/email update — with no grant insert ever firing post-L4. + it('single-org: re-runs after a sys_user insert and after an email_verified update (#11973)', async () => { + await boot(); + const runAll = async (opCtx: any) => { + for (const mw of middlewares) await mw(opCtx, async () => {}); + }; + await runAll({ object: 'sys_user', operation: 'insert' }); + expect(ql.tables.sys_member).toHaveLength(1); + // The verifying update fires the bootstrap too (idempotent second pass). + await runAll({ object: 'sys_user', operation: 'update', data: { email_verified: true } }); + expect(ql.tables.sys_member).toHaveLength(1); + // A sys_user update touching NEITHER standing column costs no run at + // all — asserted on `find`, which any fired pass must call first. + ql.find.mockClear(); + await runAll({ object: 'sys_user', operation: 'update', data: { name: 'renamed' } }); + expect(ql.find).not.toHaveBeenCalled(); + }); + it('single-org: idempotent — second kernel:ready pass is a no-op', async () => { await boot(); await hookCapture.trigger('kernel:ready'); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 954bdc1745..082b84fde3 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -32,7 +32,10 @@ import { readMcpServerEnabledEnv, type AuthManagerOptions, } from './auth-manager.js'; -import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { + ensureDefaultOrganization, + isDefaultOrganizationBootstrapTrigger, +} from './ensure-default-organization.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; import type { AuthEventAuditSurface } from './auth-session-audit.js'; @@ -181,10 +184,13 @@ export interface AuthPluginOptions extends Partial { * organization, so sessions carry no `activeOrganizationId` and better-auth * `organization/invite-member` has no org to resolve — i.e. no way to add a * user at all. When enabled (default), the plugin idempotently creates the - * `Default Organization` (slug `default`) and binds the first platform - * admin as `owner`, on `kernel:ready` and after every - * `sys_user_permission_set` insert. Inert in multi-org mode — the - * enterprise organizations package owns the bootstrap there. + * `Default Organization` (slug `default`) and binds the platform admin as + * `owner`, on `kernel:ready` and after every write matched by + * `isDefaultOrganizationBootstrapTrigger` (a `sys_user` insert or + * email/email_verified update — the config-anchor trigger set — plus the + * legacy `sys_user_permission_set` insert that `single`-posture first-user + * promotion still writes). Inert in multi-org mode — the enterprise + * organizations package owns the bootstrap there. * @default true */ autoDefaultOrganization?: boolean; @@ -1047,18 +1053,20 @@ export class AuthPlugin implements Plugin { } }; ctx.hook('kernel:ready', runEnsure); - // Re-run after every admin grant — covers the "first sign-up promoted - // to platform admin" case where kernel:ready fired before any user - // existed (same wiring the multi-org bootstrap uses). + // [#11973 / #11663 L3] Re-run after every write that can move the + // population answer, judged by the ONE exported trigger predicate: a + // `sys_user` insert or email/email_verified update (the #11343 trigger + // set — how a CONFIG-anchored admin comes into standing), and the + // legacy `sys_user_permission_set` insert (how `single`-posture + // first-user promotion lands standing, Choice 4A — retired with the + // legacy-grant removal leg). The enterprise organizations package's + // walled wiring should consume the same predicate. try { const ql = ctx.getService('objectql'); if (ql && typeof ql.registerMiddleware === 'function') { ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { await next(); - if ( - opCtx?.object === 'sys_user_permission_set' && - (opCtx?.operation === 'insert' || opCtx?.operation === 'create') - ) { + if (isDefaultOrganizationBootstrapTrigger(opCtx)) { await runEnsure(); } }); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index ffe62f36cd..0b34e8f5e6 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -4,8 +4,34 @@ // Covers the idempotency short-circuits, the create/reuse paths, and the // injectable seed-ownership step (enterprise injects it; open path omits it). -import { describe, it, expect, vi } from 'vitest'; -import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { + ensureDefaultOrganization, + isDefaultOrganizationBootstrapTrigger, +} from './ensure-default-organization.js'; + +// [#11973] The config anchor reads `OS_PLATFORM_OWNER_EMAIL` live (memoized on +// the raw value), so every case in this file pins the variable's state instead +// of inheriting the ambient environment's. +const ENV = 'OS_PLATFORM_OWNER_EMAIL'; +let ambientOwnerEmail: string | undefined; +beforeEach(() => { + ambientOwnerEmail = process.env[ENV]; + delete process.env[ENV]; + resetPlatformAdminEmailMemo(); +}); +afterEach(() => { + if (ambientOwnerEmail === undefined) delete process.env[ENV]; + else process.env[ENV] = ambientOwnerEmail; + resetPlatformAdminEmailMemo(); +}); + +/** Declare the deployment's administrators and drop the raw-value memo. */ +function declare(value: string): void { + process.env[ENV] = value; + resetPlatformAdminEmailMemo(); +} type Row = Record; @@ -17,6 +43,7 @@ function makeQl(seed: Partial> = {}) { ], sys_member: [], sys_organization: [], + sys_user: [], ...seed, }; const matches = (row: Row, where: Row) => @@ -179,4 +206,130 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => { expect(sink.seen).toHaveLength(1); }); }); + + // [#11973 / #11663 L3] The config-anchored population — design §2 step 5. + describe('config-anchored population (#11973)', () => { + const OWNER = 'owner@corp.example'; + + it('finds a declared, VERIFIED administrator with NO grant row anywhere (post-L4 walled population)', async () => { + declare(OWNER); + const ql = makeQl({ + sys_user_permission_set: [], + sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }], + }); + const res = await ensureDefaultOrganization(ql); + expect(res.memberCreated).toBe(true); + expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u_cfg', role: 'owner' }); + }); + + it('prefers the config anchor over the legacy grant anchor (the derivation prefers config)', async () => { + declare(OWNER); + const ql = makeQl({ + sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }], + }); + // The default fixture also carries the legacy grant admin `u1`. + await ensureDefaultOrganization(ql); + expect(ql.tables.sys_member[0].user_id).toBe('u_cfg'); + }); + + it('an UNVERIFIED declared account confers nothing — falls back to the legacy grant anchor', async () => { + declare(OWNER); + const ql = makeQl({ + sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: false }], + }); + await ensureDefaultOrganization(ql); + expect(ql.tables.sys_member[0].user_id).toBe('u1'); + }); + + it('declared but nobody registered, and no grants: no_admin — the trigger set re-runs it later', async () => { + declare(OWNER); + const ql = makeQl({ sys_user_permission_set: [] }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }); + }); + + it('operator order decides between several declared administrators with standing', async () => { + declare('first@corp.example,second@corp.example'); + const ql = makeQl({ + sys_user_permission_set: [], + sys_user: [ + { id: 'u_second', email: 'second@corp.example', email_verified: true }, + { id: 'u_first', email: 'first@corp.example', email_verified: true }, + ], + }); + await ensureDefaultOrganization(ql); + expect(ql.tables.sys_member[0].user_id).toBe('u_first'); + }); + + it('an entry with no verified account is passed over for the next declared entry', async () => { + declare('first@corp.example,second@corp.example'); + const ql = makeQl({ + sys_user_permission_set: [], + sys_user: [{ id: 'u_second', email: 'second@corp.example', email_verified: true }], + }); + await ensureDefaultOrganization(ql); + expect(ql.tables.sys_member[0].user_id).toBe('u_second'); + }); + + it('a REFUSED variable (unparseable entry) fails the whole list closed — legacy anchor answers', async () => { + declare(`${OWNER},not an email`); + const ql = makeQl({ + sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }], + }); + await ensureDefaultOrganization(ql); + // Choice 2B: the whole variable is refused, never the one entry — so the + // verified declared account confers nothing and the grant admin is bound. + expect(ql.tables.sys_member[0].user_id).toBe('u1'); + }); + + it('queries the VERBATIM spelling too — an imported row that is not stored lowercased is found', async () => { + declare('Ada@Example.com'); + const ql = makeQl({ + sys_user_permission_set: [], + // The fake driver is an exact-match store, so the normalized + // (lowercased) lookup misses this row; only the as-typed spelling hits. + sys_user: [{ id: 'u_ada', email: 'Ada@Example.com', email_verified: true }], + }); + await ensureDefaultOrganization(ql); + expect(ql.tables.sys_member[0].user_id).toBe('u_ada'); + }); + + // The Choice 4A pin the PM asked for by name: with the variable UNSET, a + // verified `sys_user` row is NOT a population candidate. If the re-point + // leaked into the `single` branch (any verified user read as an admin + // candidate), `u_other` would win the bind below and this goes red. + it('config UNSET: a verified sys_user row is NOT an admin candidate — the grant anchor decides (Choice 4A)', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u_other', email: 'other@corp.example', email_verified: true }], + }); + const res = await ensureDefaultOrganization(ql); + expect(res.memberCreated).toBe(true); + expect(ql.tables.sys_member[0].user_id).toBe('u1'); + // …and the config half cost no sys_user read at all. + expect(ql.find).not.toHaveBeenCalledWith('sys_user', expect.anything(), expect.anything()); + }); + }); +}); + +// [#11973 / #11663 L3, design H4] The trigger predicate — one definition for +// every wiring (plugin-auth's middleware here; the enterprise organizations +// package's walled wiring is asked to consume the same export). +describe('isDefaultOrganizationBootstrapTrigger', () => { + it.each([ + [{ object: 'sys_user', operation: 'insert' }, true], + [{ object: 'sys_user', operation: 'create' }, true], + [{ object: 'sys_user', operation: 'update', data: { email_verified: true } }, true], + [{ object: 'sys_user', operation: 'update', data: { email: 'x@y.example' } }, true], + [{ object: 'sys_user', operation: 'update', data: { name: 'renamed' } }, false], + [{ object: 'sys_user', operation: 'update' }, false], + [{ object: 'sys_user', operation: 'delete' }, false], + [{ object: 'sys_user_permission_set', operation: 'insert' }, true], + [{ object: 'sys_user_permission_set', operation: 'create' }, true], + [{ object: 'sys_user_permission_set', operation: 'update', data: { organization_id: null } }, false], + [{ object: 'sys_member', operation: 'insert' }, false], + [{ object: 'task', operation: 'insert' }, false], + [{}, false], + ])('%j → %s', (opCtx, expected) => { + expect(isDefaultOrganizationBootstrapTrigger(opCtx as any)).toBe(expected); + }); }); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 5d7bbaf4fd..a9f99eb0af 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -3,9 +3,8 @@ /** * ensureDefaultOrganization — default-org bootstrap helper (ADR-0081 D1). * - * The platform admin (`admin_full_access` granted with `organization_id IS - * NULL`) needs at least one `sys_organization` so their sessions can carry an - * `activeOrganizationId`. Without it: + * The platform admin needs at least one `sys_organization` so their sessions + * can carry an `activeOrganizationId`. Without it: * - multi-org: the default `tenant_isolation` RLS policy filters everything * to zero rows and the admin sees an empty console; * - single-org: better-auth `organization/invite-member` has no active org @@ -17,12 +16,36 @@ * injects its seed-ownership step via `claimSeedOwnership` (that machinery is * part of the per-org seed pipeline, not of the basics). * - * Strategy (idempotent, run on `kernel:ready` and after every - * `sys_user_permission_set` insert): + * ## Who "the platform admin" is (#11973 / #11663 L3, design §2 step 5) * - * 1. Find the platform admin (oldest `sys_user_permission_set` row with - * `permission_set_id = admin_full_access` and `organization_id IS - * NULL`). If none, no-op. + * The POPULATION question this helper asks — "which user is the platform + * admin?" — has TWO anchors since the #11663 re-anchor, read in this order: + * + * 1. **Config anchor** (preferred, mirroring the derivation's own + * preference): the first `OS_PLATFORM_OWNER_EMAIL` entry, in the order + * the operator declared them, that a stored `sys_user` row holds with + * `email_verified` reading VERIFIED. The membership + verified predicate + * is `matchesConfiguredPlatformAdmin` from `@objectstack/core` — the + * derivation site's own (`resolve-authz-context.ts` §6b-config) — so + * this file re-implements NOTHING of the parse, the normalization or the + * fail-closed verified read (⛔ no second derivation site). The lookup + * shape — both spellings queried, matches re-checked through the shared + * predicate, oldest verified row wins — is the same one + * `plugin-security`'s `resolvePlatformAdminStanding` serves the audit + * surface with, so the account this helper binds is the account the + * audit surface reports as holding standing. + * 2. **Legacy grant anchor** (Choice 4A + P5): the oldest unscoped + * `sys_user_permission_set` row on `admin_full_access`, exactly as + * before. This is what still anchors `single`-posture deployments + * (first-user promotion keeps writing the grant row there, by ruling) + * and pre-migration walled deployments inside P5's honoured window. It + * is removed with the legacy-grant removal leg (design §5 step 6), not + * here — nothing in this leg is subtractive. + * + * Strategy (idempotent, run on `kernel:ready` and after every write matched + * by {@link isDefaultOrganizationBootstrapTrigger}): + * + * 1. Find the platform admin (two anchors, above). If none, no-op. * 2. If that user already has any `sys_member` row, no-op (they either * created their own org or were invited into one — we respect that and * never auto-create a "Default Organization" behind their back). @@ -34,6 +57,8 @@ * 5. (optional, injected) hand the org's seeded rows to the admin. */ +import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; + interface BootstrapLogger { info: (message: string, meta?: Record) => void; /** @@ -137,6 +162,100 @@ function genId(prefix: string): string { return `${prefix}_${ts}${rand}`; } +/** Oldest `created_at` first; rows with no timestamp sort first (epoch 0). */ +function oldestFirst(a: any, b: any): number { + 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; +} + +/** + * [#11973 / #11663 L3, design H4] Which writes can change this helper's + * answer — the trigger predicate for the default-org bootstrap re-run + * middleware. Exported so every wiring (plugin-auth's single-posture + * middleware, and the enterprise organizations package's walled bootstrap + * wiring) consumes the SAME predicate instead of re-deriving it — the + * `shouldReplayBootstrapFor` pattern next door in `plugin-security`. + * + * Three arms, one per way the POPULATION answer can move: + * + * - **`sys_user` insert/create** — the config anchor: an + * operator-provisioned account (`walled-owner-operator-stamp.ts`) or a + * trusted-IdP insert arrives ALREADY VERIFIED, so the row that confers + * standing can exist the moment it is created. + * - **`sys_user` update touching `email` / `email_verified`** — the #11343 + * trigger set (design §2 step 5): the declared owner's verifying update is + * exactly the moment `matchesConfiguredPlatformAdmin` starts answering + * `true`, and on a fresh walled rig it is the ONLY write that ever will — + * post-L4 no grant row is minted there, so a grant-insert trigger never + * fires again (the interim window this leg closes). + * - **`sys_user_permission_set` insert/create** — the LEGACY anchor's + * trigger, kept verbatim: `single`-posture first-user promotion still + * lands standing as a grant insert (Choice 4A), and P5's honoured window + * still admits legacy walled grants. This arm is retired with the + * legacy-grant removal leg (design §5 step 6), together with the grant + * read it serves — ⛔ not as a side effect of this re-point. + * + * A `sys_user` DELETE never grows the population (the simulation direction + * `last-admin-guard.ts` documents), and an update touching neither standing + * column provably cannot move the answer — both cost no re-run. + */ +export function isDefaultOrganizationBootstrapTrigger(opCtx: { + object?: string; + operation?: string; + data?: unknown; +}): boolean { + const op = opCtx?.operation; + if (opCtx?.object === 'sys_user') { + 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 (opCtx?.object === 'sys_user_permission_set') { + return op === 'create' || op === 'insert'; + } + return false; +} + +/** + * The CONFIG-anchored half of the population question: the first declared + * `OS_PLATFORM_OWNER_EMAIL` entry (operator order) holding standing, resolved + * to the oldest VERIFIED `sys_user` row that carries it. `undefined` when the + * variable is unset/blank/refused or no declared address has a verified + * account yet — the caller then falls back to the legacy grant anchor. + * + * Both spellings of each entry are queried (a driver `where` is an exact + * match and an imported/legacy row may not be stored lowercased), and every + * returned row is re-checked through `matchesConfiguredPlatformAdmin` — the + * derivation site's own predicate — so a case-folding collation's extra rows + * are dropped and the verified read stays the shared fail-closed one. + */ +async function findConfigAnchoredAdminUserId(ql: any): Promise { + const config = resolvePlatformAdminEmails(); + if (config.emails.length === 0) return undefined; + 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 standing = [...byId.values()] + .filter((u) => matchesConfiguredPlatformAdmin(u, config)) + .sort(oldestFirst); + if (standing[0]?.id) return String(standing[0].id); + } + return undefined; +} + export interface EnsureDefaultOrganizationResult { /** Whether a brand-new org row was inserted (vs. re-using slug=default). */ defaultOrgCreated: boolean; @@ -164,29 +283,34 @@ export async function ensureDefaultOrganization( return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } - // 1. Find the platform admin permission-set id. - const adminPs = await tryFind(ql, 'sys_permission_set', { name: 'admin_full_access' }, 1); - if (adminPs.length === 0 || !adminPs[0].id) { - return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; - } - const adminPsId = adminPs[0].id; + // 1. Find the platform admin — CONFIG anchor first (#11973 / #11663 L3; + // the derivation prefers config, so the population read does too). + // With `OS_PLATFORM_OWNER_EMAIL` unset this costs no read at all, which + // is what keeps every `single`-posture deployment on the path below + // exactly as before (Choice 4A). + let adminUserId: string | undefined = await findConfigAnchoredAdminUserId(ql); - // 2. Find the platform admin user (oldest cross-tenant grant). - const adminGrants = await tryFind( - ql, - 'sys_user_permission_set', - { permission_set_id: adminPsId, organization_id: null }, - 50, - ); - if (adminGrants.length === 0) { - return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; + // 2. LEGACY grant anchor (oldest cross-tenant `admin_full_access` grant) — + // still what anchors `single` posture and P5's honoured migration + // window; removed with the legacy-grant removal leg (design §5 step 6). + if (!adminUserId) { + const adminPs = await tryFind(ql, 'sys_permission_set', { name: 'admin_full_access' }, 1); + if (adminPs.length === 0 || !adminPs[0].id) { + return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; + } + const adminPsId = adminPs[0].id; + const adminGrants = await tryFind( + ql, + 'sys_user_permission_set', + { permission_set_id: adminPsId, organization_id: null }, + 50, + ); + if (adminGrants.length === 0) { + return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; + } + const sortedGrants = [...adminGrants].sort(oldestFirst); + adminUserId = sortedGrants[0]?.user_id ? String(sortedGrants[0].user_id) : undefined; } - const sortedGrants = [...adminGrants].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; - }); - const adminUserId: string | undefined = sortedGrants[0]?.user_id; if (!adminUserId) { return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } @@ -231,8 +355,9 @@ export async function ensureDefaultOrganization( + 'ON LOOKING HEALTHY: this line is the only notice. Remedy: make the sys_organization ' + 'insert land — check the write permission and driver connectivity, and whether a legacy ' + 'unique index on `slug` is refusing `default`; the bootstrap re-runs on every ' - + 'kernel:ready and after every sys_user_permission_set insert, so no manual repair is ' - + 'needed once the write can land.', + + 'kernel:ready and on every default-org bootstrap trigger (sys_user insert, an ' + + 'email/email_verified update, or a legacy sys_user_permission_set insert), so no ' + + 'manual repair is needed once the write can land.', { object: 'sys_organization', slug: 'default' }, ); return { defaultOrgCreated: false, memberCreated: false, reason: 'org_insert_failed' }; @@ -257,7 +382,8 @@ export async function ensureDefaultOrganization( + 'the deployment looks healthier than it is and this line is the only notice. Remedy: make ' + 'the sys_member insert land — check the write permission, driver connectivity, and any ' + 'unique index over (organization_id, user_id); the bootstrap re-runs on every kernel:ready ' - + 'and after every sys_user_permission_set insert, so the next pass binds it.', + + 'and on every default-org bootstrap trigger (sys_user insert, an email/email_verified ' + + 'update, or a legacy sys_user_permission_set insert), so the next pass binds it.', { object: 'sys_member', organization: defaultOrgId, user: adminUserId }, ); return { From de9db6593b850a145ee11b4c35e9a187c0f5910e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:56:06 +0000 Subject: [PATCH 2/3] docs(plugin-auth): re-price last-admin-guard under the config anchor; retire the elevation framing from the walled-owner prose surfaces (L3 step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration step 5 of #11663 — its OWN reviewed change, separate from the re-pointing commit as the card requires. No refusal is added or deleted in code: every refusal is the output of the one resolveAdminUserIds enumeration, which has counted config-anchored administrators since L2, so pricing the population re-priced the refusals mechanically. This commit makes that a measured, pinned verdict: - OBSOLETE where the config anchor stands (pinned PERMITTED): delete / rename / deactivate of the admin_full_access sys_permission_set row, and deletion of the last legacy grant row, while a declared VERIFIED administrator stands. - KEPT where the grant anchor is load-bearing (pinned REFUSED): the identical four writes with no declared administrators (Choice 4A single-posture shape), and with a declared-but-UNVERIFIED account. - UNCHANGED: the zero-population tri-state (refuseIfEmptiedRatherThanFresh) — reachable only when no anchor stands, made rarer, never wronger; and the L2 fifth write shape (the newly-necessary refusals), verified as landed. Prose true-ups the L4 landing flagged to this card: the retired walled_owner_not_verified / elevation framing in walled-owner-verification-path.ts (semantic unchanged — an unverified declared address resolves non-admin, now at the derivation site) and the retired shouldReplayBootstrapFor create-arm coupling in walled-owner-operator-stamp.ts. Part of #11973 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- ...gin-walled-owner-verification-path.test.ts | 21 +- .../src/last-admin-guard.re-pricing.test.ts | 277 ++++++++++++++++++ .../plugin-auth/src/last-admin-guard.ts | 41 ++- .../src/walled-owner-operator-stamp.ts | 28 +- .../src/walled-owner-verification-path.ts | 84 +++--- 5 files changed, 390 insertions(+), 61 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts b/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts index 91b7d7dd58..92516b59f4 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts @@ -6,9 +6,11 @@ * A, verbatim 「全部同意」). * * ⛔ Nothing here may become a refusal: boot proceeds in EVERY shape below, - * including the one that warns. The two refusals around this check - * (`walled_owner_email_undeclared` at boot, `walled_owner_not_verified` at - * elevation) are pinned by their own suites and are untouched. + * including the one that warns. The fail-closed clauses around this check — + * the `walled_owner_email_undeclared` boot refusal, and ([#11973] since the + * #11663 L4 re-anchor) the derivation site resolving an unverified declared + * address non-admin per request — are pinned by their own suites and are + * untouched. * * The load-bearing half of this file is the CONTROLS. A warning that fires on * every boot satisfies "the dead-end shape warns" just as well as a correct @@ -110,8 +112,12 @@ describe('#11640 — the dead-end shape warns, by name and with the remedy', () // exactly here. expect(msg).toContain('Either one alone clears this'); // …and it says what goes wrong if nothing is wired, in the vocabulary of - // the refusal the owner will actually hit. - expect(msg).toContain('walled_owner_not_verified'); + // the dead end the owner will actually hit ([#11973]: no elevation + // refusal exists post-L4 — an unverified declared address simply resolves + // no standing at the derivation site). + expect(msg).toContain('NO platform-admin standing'); + expect(msg).toContain('derived at request time'); + expect(msg).not.toContain('walled_owner_not_verified'); }); it('⛔ it is a WARNING, never a refusal — the text promises boot continues', () => { @@ -287,7 +293,10 @@ describe('#12751 — the warning follows the owner account state', () => { const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified')); expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH); expect(msg).toContain('ALREADY EXISTS'); - expect(msg).toContain('walled_owner_not_verified'); + // [#11973] The dead end in the derivation's own vocabulary — the retired + // elevation refusal token must be gone. + expect(msg).toContain('NO platform-admin standing'); + expect(msg).not.toContain('walled_owner_not_verified'); }); it('a populated store with NO owner account warns — the bootstrap window is spent and an invitee arrives unverified', () => { diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts new file mode 100644 index 0000000000..7e9fdabcd6 --- /dev/null +++ b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11973 / #11663 migration step 5] The last-admin-guard RE-PRICING pins — + * the reviewed step's evidence, direction by direction. + * + * The re-anchor left this guard's refusal SET untouched in code, and these + * pins are what make that a measured verdict instead of an assumption: every + * refusal is the output of the one `resolveAdminUserIds` enumeration, so + * teaching the enumeration the config anchor (#11663 L2) re-priced the + * refusals mechanically. Two directions, both load-bearing: + * + * - **OBSOLETE where the config anchor stands.** Deleting, renaming or + * deactivating the `admin_full_access` `sys_permission_set` row — write + * shape (4), which used to un-make every platform admin in one write — no + * longer empties a population that contains a config-anchored + * administrator, so those writes are PERMITTED there. Same for deleting + * the legacy grant row itself. + * - **KEPT where the grant anchor is load-bearing.** With no declared + * administrators (`single` posture under Choice 4A, and P5's honoured + * legacy window) the identical writes still take the last administrator + * away and are still REFUSED. These counter-pins are what go red if a + * re-pricing edit — or the L3 re-point next door — ever leaks into the + * grant-anchored branch. + * + * Method as in `last-admin-guard.config-anchor.test.ts` (and for the same + * reason): a REAL ObjectQL engine over better-sqlite3 `:memory:`, so the + * engine dispatches the hooks and the store decides how booleans come back. + * + * Reverse verification, recorded: each PERMITTED pin asserts the write's + * effect landed (row gone / column moved), so a guard that refused it — the + * pre-re-anchor price — fails the test rather than merely logging. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; + +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; + +const ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const SYSTEM = { context: { isSystem: true } } as const; +const OWNER = 'owner@corp.example'; + +const sysUser = { + name: 'sys_user', + label: 'User', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + email: { name: 'email', type: 'text' as const }, + email_verified: { name: 'email_verified', type: 'boolean' as const }, + banned: { name: 'banned', type: 'boolean' as const, readonly: true }, + }, +}; + +const sysMember = { + name: 'sys_member', + label: 'Member', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + role: { name: 'role', type: 'text' as const }, + }, +}; + +const sysPermissionSet = { + name: 'sys_permission_set', + label: 'Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + label: { name: 'label', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + }, +}; + +const sysUserPermissionSet = { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + valid_from: { name: 'valid_from', type: 'datetime' as const }, + valid_until: { name: 'valid_until', type: 'datetime' as const }, + }, +}; + +let engines: ObjectQL[] = []; +let ambient: string | undefined; + +beforeEach(() => { + ambient = process.env[ENV]; + delete process.env[ENV]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + if (ambient === undefined) delete process.env[ENV]; + else process.env[ENV] = ambient; + resetPlatformAdminEmailMemo(); + const open = engines; + engines = []; + for (const e of open) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +/** Declare the deployment's administrators and drop the memo keyed on the raw value. */ +function declare(value: string): void { + process.env[ENV] = value; + resetPlatformAdminEmailMemo(); +} + +async function boot(): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet]) { + engine.registry.registerObject(o as never); + } + await engine.syncSchemas(); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard-re-pricing' }); + return engine; +} + +/** + * A grant-anchored administrator: verified user + active `admin_full_access` + * row + an unscoped in-window grant. The pre-re-anchor shape of "the last + * platform admin". + */ +async function seedGrantAdmin(engine: ObjectQL, userId = 'usr_grant'): Promise { + await engine.insert( + 'sys_user', + { id: userId, name: userId, email: `${userId}@corp.example`, email_verified: true, banned: false }, + SYSTEM, + ); + await engine.insert('sys_permission_set', { id: 'ps_admin', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); + await engine.insert( + 'sys_user_permission_set', + { id: 'ups_admin', user_id: userId, permission_set_id: 'ps_admin' }, + SYSTEM, + ); +} + +/** A config-anchored administrator: a verified `sys_user` row on the declared list. */ +async function seedConfigAdmin(engine: ObjectQL, userId = 'usr_owner'): Promise { + await engine.insert( + 'sys_user', + { id: userId, name: userId, email: OWNER, email_verified: true, banned: false }, + SYSTEM, + ); +} + +async function findOne(engine: ObjectQL, object: string, id: string): Promise { + return engine.findOne(object, { where: { id } }, SYSTEM); +} + +describe('[#11973] OBSOLETE refusals — shape-(4) writes are permitted while a config-anchored administrator stands', () => { + it('DELETING the admin_full_access row is permitted, and lands', async () => { + declare(OWNER); + const engine = await boot(); + await seedConfigAdmin(engine); + await seedGrantAdmin(engine); // the row also carries a live grant — still not the last anchor + + await expect( + engine.delete('sys_permission_set', { where: { id: 'ps_admin' }, ...SYSTEM }), + ).resolves.toBeDefined(); + expect(await findOne(engine, 'sys_permission_set', 'ps_admin')).toBeFalsy(); + }); + + it('DEACTIVATING it (ADR-0049 spelling) is permitted, and lands', async () => { + declare(OWNER); + const engine = await boot(); + await seedConfigAdmin(engine); + await seedGrantAdmin(engine); + + await engine.update('sys_permission_set', { id: 'ps_admin', active: false }, SYSTEM); + const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { active?: unknown }; + expect(row?.active).toBeFalsy(); + }); + + it('RENAMING it is permitted, and lands', async () => { + declare(OWNER); + const engine = await boot(); + await seedConfigAdmin(engine); + await seedGrantAdmin(engine); + + await engine.update('sys_permission_set', { id: 'ps_admin', name: 'renamed_away' }, SYSTEM); + const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { name?: unknown }; + expect(row?.name).toBe('renamed_away'); + }); + + it('deleting the LAST legacy grant row is permitted, and lands', async () => { + declare(OWNER); + const engine = await boot(); + await seedConfigAdmin(engine); + await seedGrantAdmin(engine); + + await expect( + engine.delete('sys_user_permission_set', { where: { id: 'ups_admin' }, ...SYSTEM }), + ).resolves.toBeDefined(); + expect(await findOne(engine, 'sys_user_permission_set', 'ups_admin')).toBeFalsy(); + }); +}); + +describe('[#11973] KEPT refusals — the same writes still refuse where the grant anchor is load-bearing (Choice 4A / P5)', () => { + it('with NO declared administrators, deleting the admin_full_access row is still refused', async () => { + const engine = await boot(); // ENV cleared in beforeEach — the `single` shape + await seedGrantAdmin(engine); + + await expect( + engine.delete('sys_permission_set', { where: { id: 'ps_admin' }, ...SYSTEM }), + ).rejects.toThrow(/administrator/i); + expect(await findOne(engine, 'sys_permission_set', 'ps_admin')).toBeTruthy(); + }); + + it('…deactivating it is still refused', async () => { + const engine = await boot(); + await seedGrantAdmin(engine); + + await expect( + engine.update('sys_permission_set', { id: 'ps_admin', active: false }, SYSTEM), + ).rejects.toThrow(/administrator/i); + const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { active?: unknown }; + expect(row?.active).toBeTruthy(); + }); + + it('…renaming it is still refused', async () => { + const engine = await boot(); + await seedGrantAdmin(engine); + + await expect( + engine.update('sys_permission_set', { id: 'ps_admin', name: 'renamed_away' }, SYSTEM), + ).rejects.toThrow(/administrator/i); + const row = (await findOne(engine, 'sys_permission_set', 'ps_admin')) as { name?: unknown }; + expect(row?.name).toBe(ADMIN_FULL_ACCESS); + }); + + it('…deleting the last grant row is still refused', async () => { + const engine = await boot(); + await seedGrantAdmin(engine); + + await expect( + engine.delete('sys_user_permission_set', { where: { id: 'ups_admin' }, ...SYSTEM }), + ).rejects.toThrow(/administrator/i); + expect(await findOne(engine, 'sys_user_permission_set', 'ups_admin')).toBeTruthy(); + }); + + it('a DECLARED-but-unverified account does not re-price anything — the refusal holds', async () => { + declare(OWNER); + const engine = await boot(); + // The declared address exists but is NOT verified: it confers nothing at + // the derivation site, so it must relax nothing here either. + await engine.insert( + 'sys_user', + { id: 'usr_owner', name: 'usr_owner', email: OWNER, email_verified: false, banned: false }, + SYSTEM, + ); + await seedGrantAdmin(engine); + + await expect( + engine.delete('sys_permission_set', { where: { id: 'ps_admin' }, ...SYSTEM }), + ).rejects.toThrow(/administrator/i); + }); +}); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index e6fe8d31f4..2926688427 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -35,7 +35,13 @@ * else, or (ADR-0049, since `active` became a resolution-time predicate) * switch it off, and every grant, every `sys_user` row and every * `sys_member` row survives untouched while nobody is a platform admin any - * more — one write, the whole platform-admin population. Unlike (3) this one + * more — one write, the whole GRANT-anchored platform-admin population. + * ([#11973] Since the #11663 re-anchor that is no longer the whole + * population: a CONFIG-anchored administrator — shape (5)'s subject — + * survives every write to this table, so where one stands these three + * refusals are priced away by the enumeration itself and the write is + * permitted; they still hold wherever the row remains the load-bearing + * anchor. See the re-pricing note in `resolveAdminUserIds`.) Unlike (3) this one * is not driven by an IdP at all: it is written by a metadata delete, an * `os meta` run, a package uninstall — or, for the deactivation spelling, by * one click on a Setup row action that carries no visibility or condition @@ -108,14 +114,21 @@ * guard is unchanged and still counts both, so the definition stands on its own * here, enumerated in the opposite direction: * - * 1. **platform admin** — an UNSCOPED (`organization_id = null`), in-window - * (ADR-0091) `sys_user_permission_set` grant of `admin_full_access`. This - * is the same evidence `resolveAuthzContext` derives `platform_admin` from + * 1. **platform admin, grant-anchored** — an UNSCOPED + * (`organization_id = null`), in-window (ADR-0091) + * `sys_user_permission_set` grant of `admin_full_access`. This is the same + * evidence `resolveAuthzContext` derives `platform_admin` from * (ADR-0068 D2 / ADR-0095 D3) — never a stored `sys_user.role` string. * 2. **organization owner / admin** — a `sys_member` row whose role carries * the `owner` or `admin` grade (ADR-0108's closed vocabulary). Grade, not * capability: it is read here only as "who administers this org", which is * the standing ADR-0057 D4 leaves on that column. + * 3. **platform admin, config-anchored** (#11663 L2) — a `sys_user` row whose + * own verified `email` is on the deployment's declared + * `OS_PLATFORM_OWNER_EMAIL` list, counted through the resolver's own + * `matchesConfiguredPlatformAdmin` so the enumeration and the derivation + * cannot disagree. This is the class shape (5) protects — and the class + * whose survival re-prices the shape-(4) refusals ([#11973], above). * * `delegated_admin` deliberately does NOT count. ADR-0105 D8 defines it as a * grade that can REACH an endpoint, carrying no authority of its own — counting @@ -939,11 +952,21 @@ export function registerLastAdminGuard( // which is why every deployment that has not adopted the config anchor // sees this guard behave exactly as it did. // - // ⛔ NOT re-priced here: which of this guard's REFUSALS become obsolete - // once no runtime write can empty the platform-admin population is a - // separate, reviewed step (design §5 step 5). This addition is only the - // half that keeps the count honest — it can make the guard refuse MORE, - // never less. + // [#11973] RE-PRICED (design §5 step 5 — the reviewed step the note + // here used to defer to). The re-pricing needed NO per-refusal edits, + // and that is a property worth stating rather than assuming: every + // refusal in this file is the OUTPUT of this one enumeration, so + // pricing the population re-prices them all mechanically. Concretely: + // a shape-(4) permission-set write (delete / rename / deactivate) is + // PERMITTED while a config-anchored administrator stands — exactly the + // refusals the re-anchor made obsolete, self-relaxed by the set + // arithmetic — and the same write is still REFUSED where the row + // remains the load-bearing anchor (`single` posture under Choice 4A, + // and P5's honoured legacy window). The zero-population tri-state + // (`refuseIfEmptiedRatherThanFresh`) keeps its price unchanged: it is + // reachable only when no administrator of ANY anchor stands, which the + // config anchor makes strictly rarer, never wronger. Each direction is + // pinned in `last-admin-guard.re-pricing.test.ts`. // The `where` pushes the NORMALIZED addresses down, and the predicate // re-checks each returned row in JS — the same two-step // `auth-manager.ts`'s invitation lookup argues for, for the same two diff --git a/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts b/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts index 3e25556f02..de2ea501c4 100644 --- a/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts +++ b/packages/plugins/plugin-auth/src/walled-owner-operator-stamp.ts @@ -15,8 +15,12 @@ * `maybeSeedDevAdmin`: "provisioned by the deployment's own boot command with * operator-known credentials — not an unknown self-registrant") to * production walled boots, whose owner previously had NO in-product way to - * ever satisfy the verified-elevation invariant when no mail transport and + * ever satisfy the verified-standing invariant when no mail transport and * no federated sign-in were wired (`WALLED_OWNER_NO_VERIFICATION_PATH`). + * ([#11973] Post-#11663-L4 the invariant is enforced at the derivation site + * — `resolve-authz-context.ts` §6b-config, an unverified declared address + * resolves non-admin per request — rather than by an elevation write; the + * stamp's value is unchanged.) * Rejected alternatives, from the same ruling: mandating a mail transport * out of the box, and a separate CLI stamp command. * @@ -64,11 +68,12 @@ * ## The bounds (the contract, not suggestions) * * - ONLY under the walled posture family (`postureEnforcesWall` over the - * REQUESTED posture — the same input the elevation gate reads, for the + * REQUESTED posture — the same input the walled bootstrap reads, for the * same fail-stricter reason documented in `bootstrapPlatformAdmin`). * - ONLY the account whose email equals the declared - * `OS_PLATFORM_OWNER_EMAIL`, compared the way the elevation gate compares - * (trimmed, case-insensitive — mirrored, not reinvented). + * `OS_PLATFORM_OWNER_EMAIL`, compared the way the derivation site compares + * (the one shared parser + membership predicate — mirrored, not + * reinvented). * - ONLY at CREATION, through the seam below. A later email UPDATE to the * owner address inherits nothing: the decision is staged from the * creation-time admission gate and consumed once by the `user.create` @@ -85,9 +90,13 @@ * admission seam every creation path flows through, where the vendor's own * `source.method` signal and the bootstrap probe already exist) and consumes * it in the composed `user.create.before` database hook, so the row is BORN - * `emailVerified: true` — the same shape as a trusted-SSO insert. The - * elevation itself needs no new trigger: the creation write already replays - * `bootstrapPlatformAdmin` (`shouldReplayBootstrapFor`, `create` arm). + * `emailVerified: true` — the same shape as a trusted-SSO insert. ([#11973] + * No follow-on trigger is needed: standing is DERIVED per request from the + * verified row — there is no elevation write since #11663 L4, and + * `shouldReplayBootstrapFor` deliberately never replays on walled postures — + * while the default-organization bootstrap picks the same creation up through + * its own trigger predicate, `isDefaultOrganizationBootstrapTrigger` in + * `ensure-default-organization.ts`.) */ import { resolveTenancyPosture } from '@objectstack/types'; @@ -110,8 +119,9 @@ export function isOperatorProvisionedCreation( /** * The whole stamp decision: walled posture family + declared-owner email * match + operator-provisioned creation. `false` for every other shape — - * including every shape on an unwalled deployment, where elevation never - * demands a verified owner and the stamp would be an unearned state change. + * including every shape on an unwalled deployment, where platform-admin + * standing never demands a verified owner (`single` promotes the first human + * user, Choice 4A) and the stamp would be an unearned state change. * * [#13147] The email comparison does not mirror `bootstrapPlatformAdmin`'s * owner match by re-spelling it — it IS that match: both ask the one shared diff --git a/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts b/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts index 10d73799c9..b6b9651a22 100644 --- a/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts +++ b/packages/plugins/plugin-auth/src/walled-owner-verification-path.ts @@ -15,16 +15,21 @@ * - walled + owner UNDECLARED still REFUSES STARTUP (`auth-plugin.ts` * `init()`, #11184) — this check never runs in that shape, because boot * already aborted; - * - walled + owner declared but the account unverified still REFUSES - * ELEVATION (`bootstrapPlatformAdmin`, `walled_owner_not_verified`, - * #11343) — this check is the *forecast* of that refusal, not a - * replacement for it. + * - walled + owner declared but the account unverified still CONFERS + * NOTHING. ([#11973] Since the #11663 L4 re-anchor there is no elevation + * WRITE left to refuse — platform-admin standing is derived per request + * at the one derivation site, `resolve-authz-context.ts` §6b-config, and + * an unverified account holding a declared address resolves non-admin + * there, fail-closed. The semantic this check forecasts is unchanged; + * only where it is enforced moved.) * * ## Why the deployment is a dead end * - * #11343 made walled platform-admin elevation require a VERIFIED owner-email + * #11343 made walled platform-admin standing require a VERIFIED owner-email * match (the string alone proves nothing — anyone who knows the address could - * register it first). Verification can arrive two ways: + * register it first), and the #11663 re-anchor kept the requirement while + * retiring the elevation write: the verified match is now read at request + * time. Verification can arrive two ways: * * 1. an **email transport**, which delivers the verification link, or * 2. a **trusted federated sign-in** (enterprise SSO or a social/OIDC @@ -33,10 +38,10 @@ * profile when it creates the user (`better-auth/dist/oauth2/ * link-account.mjs`, the new-user branch). * - * With NEITHER wired, the declared owner registers, is refused, and has no - * in-product way to ever satisfy the condition. Nothing else in the boot path - * notices: the refusal is correct and the deployment looks healthy until the - * one account that matters tries to sign in. + * With NEITHER wired, the declared owner registers, holds no standing, and + * has no in-product way to ever satisfy the condition. Nothing else in the + * boot path notices: the non-derivation is correct and the deployment looks + * healthy until the one account that matters tries to sign in. * * ## Why the message names the remedy * @@ -102,8 +107,8 @@ import { isHumanUserRow } from './audience-posture.js'; /** * The stable NAME of this warning — the "named" half of the ruled "loud, named * warning". It leads the message so an operator (or a support thread) can grep - * one token, the same way the elevation refusals are keyed by - * `walled_owner_email_undeclared` / `walled_owner_not_verified`. + * one token, the same way the walled bootstrap's outcomes are keyed by reasons + * like `walled_owner_email_undeclared` / `walled_config_derived`. */ export const WALLED_OWNER_NO_VERIFICATION_PATH = 'walled_owner_no_verification_path'; @@ -193,24 +198,28 @@ export interface VerificationPathWiring { /** * [#12751] Resolve {@link WalledOwnerAccountState} from the live user store. * - * Mirrors the two reads the elevation gate performs rather than inventing new - * ones: the bounded human-population page (`isBootstrapCreation`'s shape — - * humans, not rows; a FULL page of non-humans cannot prove absence and reads - * as populated) and the by-email owner lookup (both the lowercased and the - * verbatim spelling, matches re-checked trimmed + lowercased, exactly as - * `bootstrapPlatformAdmin` queries). The verified answer is the shared - * [#11343] allow-list (`isEmailVerifiedUserRow`) — the SAME predicate the - * elevation gate refuses on, so this probe can never forecast a refusal the - * gate would not make, nor stay quiet about one it would. + * Mirrors the reads the walled standing surfaces perform rather than + * inventing new ones: the bounded human-population page + * (`isBootstrapCreation`'s shape — humans, not rows; a FULL page of + * non-humans cannot prove absence and reads as populated) and the by-email + * owner lookup (both the lowercased and the verbatim spelling, matches + * re-checked through the shared predicates — the same two-spelling read + * `plugin-security`'s `resolvePlatformAdminStanding` serves the audit surface + * with). The verified answer is the shared [#11343] allow-list + * (`isEmailVerifiedUserRow`) — the SAME predicate the derivation site reads + * ([#11973]: `resolve-authz-context.ts` §6b-config, where an unverified + * declared address resolves non-admin), so this probe can never forecast a + * dead end the derivation would not produce, nor stay quiet about one it + * would. * * [#13147] `OS_PLATFORM_OWNER_EMAIL` takes one address OR a comma-separated * list (#11663 Choice 2B), so the probe asks the ONE shared parser and answers * about the DECLARED SET: `owner-verified` when at least one declared address - * has a verified account (one verified administrator is all the elevation gate - * needs to promote), `owner-unverified` when accounts exist for declared - * addresses but none is verified, `owner-absent` when none exists at all. - * Those are exactly the elevation gate's own three outcomes, which is what - * keeps this probe from forecasting a refusal the gate would not make. + * has a verified account (one verified administrator is all the derivation + * needs), `owner-unverified` when accounts exist for declared addresses but + * none is verified, `owner-absent` when none exists at all. Those are exactly + * the per-entry outcomes the walled bootstrap's standing log reports, which is + * what keeps this probe from forecasting a dead end that surface would not. * * Never throws: any unanswerable read is `'unknown'`. */ @@ -227,8 +236,8 @@ export async function probeWalledOwnerAccountState( return Array.isArray(records) ? (records as Record[]) : []; }; try { - // Both spellings for EVERY declared address, exactly as the elevation gate - // queries them — the as-typed forms come from the parser's own + // Both spellings for EVERY declared address, exactly as the standing + // resolver queries them — the as-typed forms come from the parser's own // `declaredSpellings`, never from splitting the raw value a second time. const spellings = [...new Set([...config.emails, ...config.declaredSpellings])]; const byId = new Map>(); @@ -345,21 +354,21 @@ export function resolveWalledOwnerVerificationPathWarning( state === 'owner-unverified' ? 'An account holding a declared address ALREADY EXISTS and is NOT verified — it was created ' + 'outside the operator provisioning path (the #12751 stamp applies at operator-provisioned ' + - 'CREATION only), so elevation keeps being refused (walled_owner_not_verified) and the ' + - 'account has no in-product way to satisfy the condition. ' + 'CREATION only), so it holds NO platform-admin standing (an unverified declared address ' + + 'resolves non-admin at request time) and has no in-product way to satisfy the condition. ' : state === 'owner-absent' ? 'Human users already exist but none holds a declared address, so the first-account bootstrap ' + 'window (whose owner-email creation would have been stamped verified) is spent; an ' + - 'invitation-admitted registration arrives UNVERIFIED, would be refused ' + - '(walled_owner_not_verified), and would have no in-product way to satisfy the condition. ' + 'invitation-admitted registration arrives UNVERIFIED, would hold NO platform-admin ' + + 'standing, and would have no in-product way to satisfy the condition. ' : state === 'no-human-users' ? `The dev-admin seed is armed and will provision '${devSeedAdminEmail()}' as the FIRST ` + 'account at kernel:ready, spending the bootstrap carve-out on an address that is not ' + - 'the declared owner — the owner then registers later, is refused ' + - '(walled_owner_not_verified), and has no in-product way to satisfy the condition. ' + 'the declared owner — the owner then registers later, holds NO platform-admin ' + + 'standing, and has no in-product way to satisfy the condition. ' : 'The user store could not be consulted at boot, so the declared administrators\' account state ' + - 'is unknown; an owner account not created through an operator provisioning path is ' + - 'refused (walled_owner_not_verified) with no in-product way to satisfy the condition. '; + 'is unknown; an owner account not created through an operator provisioning path holds ' + + 'NO platform-admin standing, with no in-product way to satisfy the condition. '; return ( `[auth] ${WALLED_OWNER_NO_VERIFICATION_PATH}: tenancy posture '${posture}' declares its ` + @@ -373,7 +382,8 @@ export function resolveWalledOwnerVerificationPathWarning( 'but this deployment has NO way ' + 'to verify those addresses — no email transport is wired AND no trusted federated sign-in ' + '(enterprise SSO or a social/OIDC provider) is configured. Boot continues, but ' + - 'platform-admin elevation requires a declared administrator\'s address to be VERIFIED. ' + + 'platform-admin standing is derived at request time and requires a declared ' + + 'administrator\'s address to be VERIFIED. ' + situation + 'Wire EITHER path: (1) an EMAIL ' + 'TRANSPORT — register an email service (EmailServicePlugin + OS_EMAIL_*), which delivers ' + From df17ff0a721914cc253e1e3e8faae2c37cc505f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:17:45 +0000 Subject: [PATCH 3/3] docs(permissions): re-anchor system-context census lines moved by the L3 diff Mechanical: node scripts/check-system-context-census.mjs --fix rewrote two line anchors (auth-plugin.ts:1288 -> 1296, last-admin-guard.ts:273 -> 286) that my comment-only insertions above them had rotted. Gate green after. Part of #11973 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- content/docs/permissions/system-context.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0d5e887c30..0b10859ddb 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -97,7 +97,7 @@ that silently does not happen. | 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3827` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | -| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1288` | +| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1296` | | 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` | | 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` | | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | @@ -198,7 +198,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9457`–`9474` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1451` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | -| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:273` | +| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` | ---