diff --git a/.changeset/walled-elevation-verified-email.md b/.changeset/walled-elevation-verified-email.md new file mode 100644 index 0000000000..8e21fb2af3 --- /dev/null +++ b/.changeset/walled-elevation-verified-email.md @@ -0,0 +1,42 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/plugin-auth': patch +--- + +Walled platform-admin elevation now requires the owner-email match to be +VERIFIED, and the bootstrap re-runs on the verifying update (#11343) + +Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched +the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on +`sys_user` — with no `email_verified` condition, while email verification is +off by default. #11211 narrowed elevation from "whoever registers first" to +"the declared owner's address" (a real and large narrowing); this closes the +remainder that card #11343 records: in the window before the owner registers, +an account created with the owner's address would still be elevated. + +Two halves, deliberately in one change: + +1. **The elevation match requires `email_verified`** (fail-closed allow-list + over driver representations; an absent field on an imported/legacy row + reads as unverified). An unverified holder of the owner's address is + refused like any stranger — new reason `walled_owner_not_verified`, logged + loudly with the unblock in the line. Never falls back, same direction as + the undeclared-owner refusal. +2. **The bootstrap-replay middleware now also fires on `sys_user` updates + touching `email_verified` / `email`** (trigger set extracted as + `shouldReplayBootstrapFor`, consumed by the middleware and its pins alike). + Verification is an UPDATE — with the old insert-only replay, requiring + verification would have refused the genuine owner at sign-up and then + never looked again, leaving the platform without any administrator. + +`single` posture is untouched both ways: first-user promotion (ruled +reasonable in #11184) does not gain a verification requirement, and the +owner-email variable is still never consulted there. Both directions are +pinned: the unverified holder is refused AND the verified owner is elevated — +including across the refuse-then-verify-then-re-run sequence. + +The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with +`email_verified` stamped: it is created by the deployment's own boot command +with operator-known credentials — the same trust shape as a trusted-SSO +insert, not an unknown self-registrant — so walled dev/harness boots keep a +promotable declared owner. The generic sign-up path is unchanged. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index b78e74c482..3829572b6a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1589,6 +1589,41 @@ export class AuthPlugin implements Plugin { // (auth-manager.ts) lets this through on an empty DB even when sign-up // is otherwise disabled. await api.signUpEmail({ body: { email, password, name } }); + // [#11343] Stamp the seeded admin's address VERIFIED. This account is + // provisioned by the deployment's own boot command with operator-known + // credentials — it is not an unknown self-registrant, which is the class + // the verified-elevation invariant exists to refuse. Under walled + // postures elevation now requires the declared owner's email match to be + // VERIFIED, and in a dev/harness walled boot the declared owner is this + // very account (the verify harness exports it as + // OS_PLATFORM_OWNER_EMAIL) — without the stamp a walled dev boot would + // seed an admin that can never be elevated, since no real mailbox exists + // for the verification link. Same trust shape as a trusted-SSO insert + // (`emailVerified: true` at creation). Dev-only by the NODE_ENV gate + // above; real sign-ups never pass through here. `isSystem` exempts the + // statically-readonly `email_verified` column, the same doorway the + // better-auth adapter's own verification write uses. + try { + const seededRows = await ql.find( + SystemObjectName.USER, + { where: { email }, limit: 1 }, + { context: { isSystem: true } }, + ); + const seededId = (Array.isArray(seededRows) ? seededRows[0] : undefined)?.id; + if (seededId) { + await ql.update( + SystemObjectName.USER, + { id: seededId, email_verified: true }, + { context: { isSystem: true } }, + ); + } else { + ctx.logger.warn('[auth] dev admin seeded but no row resolved for the email_verified stamp'); + } + } catch (stampErr: any) { + // Fail-open on the stamp, fail-closed on elevation: an unstamped admin + // stays unverified and walled elevation refuses it loudly. + ctx.logger.warn(`[auth] dev admin email_verified stamp failed: ${stampErr?.message ?? stampErr}`); + } ctx.logger.info(`🔑 Dev admin seeded: ${email} / ${password}`); // Surface the credentials in the `serve` startup banner. The // ctx.logger line above is swallowed by serve's boot-quiet window 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 2fa2547112..d6b20fa23e 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 @@ -18,6 +18,16 @@ * (b) single: "first user is owner" is ruled reasonable and UNCHANGED — the * owner-email variable is never consulted there. * + * [#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 * 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 @@ -26,7 +36,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +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[] } = {}) { @@ -74,10 +84,16 @@ const adminFullAccess = () => const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); -const user = (id: string, email: string, createdAt: string) => ({ +/** + * [#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. + */ +const user = (id: string, email: string, createdAt: string, extra: Record = {}) => ({ id, email, created_at: createdAt, + ...extra, }); const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; @@ -106,7 +122,9 @@ describe('walled posture + declared owner — only the owner elevates', () => { const ql = makeQl({ users: [ user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z'), - user('u_owner', 'operator@corp.example', '2026-08-23T02: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() }); @@ -122,7 +140,9 @@ describe('walled posture + declared owner — only the owner elevates', () => { process.env.OS_TENANCY_POSTURE = 'isolated'; process.env.OS_PLATFORM_OWNER_EMAIL = 'Operator@Corp.EXAMPLE'; const ql = makeQl({ - users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z')], + // [#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); @@ -232,4 +252,160 @@ 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'); }); + + 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. + const ql = makeQl({ + users: [user('u_first', 'first@corp.example', '2026-08-23T01:00:00Z', { email_verified: false })], + }); + const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); + 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(); + 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')], + }); + 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'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// [#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. +// ─────────────────────────────────────────────────────────────────────────── +describe('shouldReplayBootstrapFor — bootstrap-replay trigger set (#11343)', () => { + it('fires on sys_user insert/create (the original trigger, 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', () => { + 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', () => { + expect( + shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email: 'x@y.z' } }), + ).toBe(true); + }); + + 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('does NOT fire for other objects, other operations, or a payload-less update', () => { + expect(shouldReplayBootstrapFor({ object: 'task', operation: 'insert', data: {} })).toBe(false); + expect( + shouldReplayBootstrapFor({ object: 'task', operation: 'update', data: { email_verified: true } }), + ).toBe(false); + expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'delete', data: { id: 'u1' } })).toBe(false); + expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'find' })).toBe(false); + expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update' })).toBe(false); + }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index a9a24a3c16..4ca4c6abe9 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -18,6 +18,13 @@ * 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. * * The "create a Default Organization for the freshly-promoted admin" * behavior moved to `@objectstack/organizations` (see @@ -128,6 +135,61 @@ function genId(prefix: string): string { return `${prefix}_${ts}${rand}`; } +/** + * [#11343] Verified-email predicate for the walled elevation match — a + * fail-closed ALLOW-LIST over the representations a driver may hand back for + * the `sys_user.email_verified` boolean column (JS `true`, SQLite `1`, and + * their stringified forms). Everything else — `false`/`0`, `null`, an ABSENT + * field on an imported/legacy row, or any representation not listed — reads + * as UNVERIFIED. Absent-means-unverified is deliberate: treating a missing + * column as verified would re-open the exact hole this predicate closes for + * every row that predates the column. + */ +function isEmailVerified(u: any): boolean { + const v = u?.email_verified; + return v === true || v === 1 || v === '1' || v === 'true'; +} + +/** + * [#11343] Which `sys_user` writes can change the answer of the elevation + * query 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. + */ +export function shouldReplayBootstrapFor(opCtx: { + object?: string; + operation?: string; + data?: unknown; +}): 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; +} + /** * The platform-owned definition facets of a default permission set — the * fields the runtime resolver hydrates back into ExecutionContext @@ -375,7 +437,36 @@ export async function bootstrapPlatformAdmin( ...resyncCounts, }; } - target = oldestOf(owners); + // [#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. + const verifiedOwners = owners.filter(isEmailVerified); + if (verifiedOwners.length === 0) { + logger?.warn?.( + `[security] walled posture — an account matching the 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); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index df84ff89c9..9aaeb70467 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi } from 'vitest'; +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { SecurityPlugin } from './security-plugin.js'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { FieldMasker } from './field-masker.js'; @@ -98,6 +99,92 @@ describe('SecurityPlugin', () => { await expect(plugin.destroy()).resolves.toBeUndefined(); }); + // ------------------------------------------------------------------------- + // [#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-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. + // ------------------------------------------------------------------------- + it('re-runs the bootstrap on the verifying sys_user update, and not on an unrelated profile edit (#11343)', async () => { + const plugin = new SecurityPlugin(); + const middlewares: any[] = []; + const manifestService = { register: vi.fn() }; + // A ql fake rich enough for runBootstrap to COMPLETE (that is what arms + // the replay: `bootstrapRanOnce` is only set on completion, right before + // the 'platform bootstrap complete' log this pin counts). + const ql: any = { + registerMiddleware: (mw: any) => middlewares.push(mw), + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + insert: vi.fn(async (_o: string, d: any) => ({ id: d?.id ?? 'x' })), + // Opens with the PRODUCER's own dispatch predicate, never a hand-mirrored + // guard (check:engine-double-contract) — same shape as the makeQl double + // in bootstrap-platform-admin-walled-owner.test.ts. + update: vi.fn(async (_o: string, d: any, o?: any) => { + assertEngineUpdateDispatch(d, o); + return true; + }), + getSchema: () => undefined, + }; + const metadata = { get: async () => null, list: async () => [] }; + const services: Record = { manifest: manifestService, objectql: ql, metadata }; + const hook = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook, + }; + await plugin.init(ctx); + await plugin.start(ctx); + + // Drive the initial bootstrap to completion by firing every kernel:ready + // callback in registration order — exactly what the kernel does. start() + // registers several (runBootstrap is not the first), so firing only one + // would leave the replay disarmed and every assertion below vacuous. + const ready = hook.mock.calls.filter((c: any[]) => c[0] === 'kernel:ready'); + expect(ready.length).toBeGreaterThan(0); + for (const [, cb] of ready) await cb(); + const completions = () => + ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')).length; + // Positive control: the initial run completed — the replay is armed. If + // this line fails, the fake ql was not rich enough and every assertion + // below would be vacuous. + expect(completions()).toBe(1); + + const drive = async (opCtx: any) => { + for (const mw of middlewares) { + try { + await mw(opCtx, async () => {}); + } catch { + // Another middleware (e.g. the CRUD-authorization one) may refuse a + // bare opCtx — irrelevant here: the replay middleware never throws. + } + } + }; + + // The verifying write (better-auth flips emailVerified on link click, + // snake_cased by the adapter) ⇒ ONE re-run. + await drive({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }); + expect(completions()).toBe(2); + + // An unrelated profile edit ⇒ NO re-run. + await drive({ object: 'sys_user', operation: 'update', data: { id: 'u1', name: 'New Name' } }); + expect(completions()).toBe(2); + + // The original insert trigger still fires (control that the update leg + // did not narrow the existing behavior). + await drive({ object: 'sys_user', operation: 'insert', data: { email: 'a@b.c' } }); + expect(completions()).toBe(3); + }); + // [ADR-0105 D2 / #3623] start() hands the engine a posture accessor so the // driver-level native tenant scope can widen to the membership union under // `group`. Wired from the enforcement layer on purpose: no SecurityPlugin, diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index bdc78464bf..f2ce878e57 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -100,7 +100,7 @@ import { MaskedValueWriteError, } from './errors.js'; import { assertEngineOwnedWriteAllowed, type EngineOwnedSchemaLike } from './system-write-guard.js'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, extractMemberPairs, @@ -3309,11 +3309,23 @@ export class SecurityPlugin implements Plugin { void runBootstrap(); } - // Re-run bootstrap after a sys_user insert so the FIRST user that - // signs up after boot is auto-promoted to platform admin (and, in - // multi-tenant mode, bound to the seeded default organization) - // without requiring a server restart. The function itself is - // idempotent and bails out as soon as any platform admin exists. + // Re-run bootstrap after a sys_user write that can change the elevation + // 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. + // + // The trigger set is `shouldReplayBootstrapFor` — the SAME predicate its + // pins consume — and the function itself is idempotent, bailing out as + // soon as any platform admin exists. // // We deliberately do NOT auto-create a "personal workspace" for // every subsequent self-service signup. In a B2B / invitation- @@ -3324,10 +3336,7 @@ export class SecurityPlugin implements Plugin { // this case. ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { await next(); - if ( - opCtx?.object === 'sys_user' && - (opCtx?.operation === 'create' || opCtx?.operation === 'insert') - ) { + if (shouldReplayBootstrapFor(opCtx)) { if (bootstrapRanOnce) { await runBootstrap(); } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 6b0ee72942..72217ee437 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1491,6 +1491,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/security-plugin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts", "verb": "delete",