From ebb475e192c30e8b053638ba93c64230231eb0dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:24:13 +0000 Subject: [PATCH 1/4] fix(plugin-security): walled elevation requires a verified owner-email match, and the bootstrap replays on the verifying update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remainder #11211 left open (issue #11343): under walled postures the platform-admin elevation matched the env-declared owner email as a raw string, and email verification is off by default — so an account created with the owner's address before the owner registered would be elevated. Both halves land together, deliberately: 1. bootstrapPlatformAdmin: the walled owner-email match must be VERIFIED (fail-closed allow-list over email_verified representations; absent reads unverified). Unverified holder => refused with the new reason walled_owner_not_verified, logged loudly with the unblock in the line, never falling back — the same direction as the undeclared-owner refusal. 2. security-plugin bootstrap-replay middleware: also fires on sys_user updates touching email_verified / email (trigger set extracted as shouldReplayBootstrapFor, consumed by middleware and pins alike). Verification is an UPDATE; with the old insert-only replay, half 1 alone would refuse the genuine owner at sign-up and never look again, leaving the platform without any administrator. single posture is untouched in both directions. Pins cover both invariant directions: the unverified holder is refused AND the verified owner is elevated, including across the refuse-then-verify-then-re-run sequence. Fixes #11343 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .changeset/walled-elevation-verified-email.md | 35 ++++ ...tstrap-platform-admin-walled-owner.test.ts | 184 +++++++++++++++++- .../src/bootstrap-platform-admin.ts | 93 ++++++++- .../src/security-plugin.test.ts | 80 ++++++++ .../plugin-security/src/security-plugin.ts | 29 ++- 5 files changed, 406 insertions(+), 15 deletions(-) create mode 100644 .changeset/walled-elevation-verified-email.md diff --git a/.changeset/walled-elevation-verified-email.md b/.changeset/walled-elevation-verified-email.md new file mode 100644 index 0000000000..c5edda0d8f --- /dev/null +++ b/.changeset/walled-elevation-verified-email.md @@ -0,0 +1,35 @@ +--- +'@objectstack/plugin-security': 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. 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 d7502d32b7..7855438250 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 @@ -358,7 +420,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..a0daecafae 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -98,6 +98,86 @@ 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' })), + update: vi.fn(async () => 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 da29792526..12bf3f87e9 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, @@ -3279,11 +3279,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- @@ -3294,10 +3306,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(); } From cd8a891892d0b55017381fd1ea6816b349ce6d3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:57:21 +0000 Subject: [PATCH 2/4] test(plugin-security): route the wiring fake's update() through assertEngineUpdateDispatch; record the pinned double check:engine-double-contract named the new fake's bare update(); pin it to the producer's dispatch predicate and let the RETAINED ledger learn the new pinned coverage (--write; 0 lost, coverage grew). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .../plugins/plugin-security/src/security-plugin.test.ts | 9 ++++++++- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index a0daecafae..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'; @@ -120,7 +121,13 @@ describe('SecurityPlugin', () => { find: vi.fn(async () => []), findOne: vi.fn(async () => null), insert: vi.fn(async (_o: string, d: any) => ({ id: d?.id ?? 'x' })), - update: vi.fn(async () => true), + // 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 () => [] }; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3fdc47edad..902e80b9d0 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", From 1492e0befbeb0a99e5601dc414527efa3d3d6671 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 10:54:20 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(plugin-auth):=20seed=20the=20dev=20admi?= =?UTF-8?q?n=20with=20a=20verified=20email=20=E2=80=94=20operator-provisio?= =?UTF-8?q?ned,=20not=20a=20self-registrant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dogfood Regression Gate caught #11343's invariant end-to-end: two walled-posture fixtures (automation-toggle-tenant-scope :189, federated-rls-injectors :135) build their platform admin through the shared dev-admin seed, which drives the real sign-up pipeline and so left the account unverified — correctly refused by the restored invariant, i.e. the fixtures modeled a deployment state the platform no longer considers valid. The repair is the shared seed path, not the fixtures: maybeSeedDevAdmin now stamps email_verified on the account it just provisioned. The seeded dev admin 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, which is the class the invariant refuses. Dev-only by the existing NODE_ENV gate; the generic sign-up factory is untouched, so unverified-user flows keep testing what they name. Fail-open on the stamp, fail-closed on elevation. Part of #11343 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .../plugins/plugin-auth/src/auth-plugin.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 From c9be0f18ced013be3a166f6d530444a21742e4f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:27:52 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20cover=20@objectstack/pl?= =?UTF-8?q?ugin-auth=20=E2=80=94=20the=20dev-seed=20verified=20stamp=20shi?= =?UTF-8?q?ps=20under=20the=20same=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .changeset/walled-elevation-verified-email.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.changeset/walled-elevation-verified-email.md b/.changeset/walled-elevation-verified-email.md index c5edda0d8f..8e21fb2af3 100644 --- a/.changeset/walled-elevation-verified-email.md +++ b/.changeset/walled-elevation-verified-email.md @@ -1,5 +1,6 @@ --- '@objectstack/plugin-security': patch +'@objectstack/plugin-auth': patch --- Walled platform-admin elevation now requires the owner-email match to be @@ -33,3 +34,9 @@ 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.