Skip to content

Commit c0714eb

Browse files
os-samclaude
andauthored
fix(plugin-security): walled elevation requires a verified owner-email match, and the bootstrap replays on the verifying update (#11639)
* fix(plugin-security): walled elevation requires a verified owner-email match, and the bootstrap replays on the verifying update 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * fix(plugin-auth): seed the dev admin with a verified email — operator-provisioned, not a self-registrant 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * chore(changeset): cover @objectstack/plugin-auth — the dev-seed verified stamp ships under the same card Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ceae8a commit c0714eb

7 files changed

Lines changed: 460 additions & 15 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
Walled platform-admin elevation now requires the owner-email match to be
7+
VERIFIED, and the bootstrap re-runs on the verifying update (#11343)
8+
9+
Under walled postures (`group`/`isolated`), `bootstrapPlatformAdmin` matched
10+
the env-declared `OS_PLATFORM_OWNER_EMAIL` against the raw email string on
11+
`sys_user` — with no `email_verified` condition, while email verification is
12+
off by default. #11211 narrowed elevation from "whoever registers first" to
13+
"the declared owner's address" (a real and large narrowing); this closes the
14+
remainder that card #11343 records: in the window before the owner registers,
15+
an account created with the owner's address would still be elevated.
16+
17+
Two halves, deliberately in one change:
18+
19+
1. **The elevation match requires `email_verified`** (fail-closed allow-list
20+
over driver representations; an absent field on an imported/legacy row
21+
reads as unverified). An unverified holder of the owner's address is
22+
refused like any stranger — new reason `walled_owner_not_verified`, logged
23+
loudly with the unblock in the line. Never falls back, same direction as
24+
the undeclared-owner refusal.
25+
2. **The bootstrap-replay middleware now also fires on `sys_user` updates
26+
touching `email_verified` / `email`** (trigger set extracted as
27+
`shouldReplayBootstrapFor`, consumed by the middleware and its pins alike).
28+
Verification is an UPDATE — with the old insert-only replay, requiring
29+
verification would have refused the genuine owner at sign-up and then
30+
never looked again, leaving the platform without any administrator.
31+
32+
`single` posture is untouched both ways: first-user promotion (ruled
33+
reasonable in #11184) does not gain a verification requirement, and the
34+
owner-email variable is still never consulted there. Both directions are
35+
pinned: the unverified holder is refused AND the verified owner is elevated —
36+
including across the refuse-then-verify-then-re-run sequence.
37+
38+
The seeded dev admin (`maybeSeedDevAdmin`, dev-only) is now provisioned with
39+
`email_verified` stamped: it is created by the deployment's own boot command
40+
with operator-known credentials — the same trust shape as a trusted-SSO
41+
insert, not an unknown self-registrant — so walled dev/harness boots keep a
42+
promotable declared owner. The generic sign-up path is unchanged.

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,6 +1589,41 @@ export class AuthPlugin implements Plugin {
15891589
// (auth-manager.ts) lets this through on an empty DB even when sign-up
15901590
// is otherwise disabled.
15911591
await api.signUpEmail({ body: { email, password, name } });
1592+
// [#11343] Stamp the seeded admin's address VERIFIED. This account is
1593+
// provisioned by the deployment's own boot command with operator-known
1594+
// credentials — it is not an unknown self-registrant, which is the class
1595+
// the verified-elevation invariant exists to refuse. Under walled
1596+
// postures elevation now requires the declared owner's email match to be
1597+
// VERIFIED, and in a dev/harness walled boot the declared owner is this
1598+
// very account (the verify harness exports it as
1599+
// OS_PLATFORM_OWNER_EMAIL) — without the stamp a walled dev boot would
1600+
// seed an admin that can never be elevated, since no real mailbox exists
1601+
// for the verification link. Same trust shape as a trusted-SSO insert
1602+
// (`emailVerified: true` at creation). Dev-only by the NODE_ENV gate
1603+
// above; real sign-ups never pass through here. `isSystem` exempts the
1604+
// statically-readonly `email_verified` column, the same doorway the
1605+
// better-auth adapter's own verification write uses.
1606+
try {
1607+
const seededRows = await ql.find(
1608+
SystemObjectName.USER,
1609+
{ where: { email }, limit: 1 },
1610+
{ context: { isSystem: true } },
1611+
);
1612+
const seededId = (Array.isArray(seededRows) ? seededRows[0] : undefined)?.id;
1613+
if (seededId) {
1614+
await ql.update(
1615+
SystemObjectName.USER,
1616+
{ id: seededId, email_verified: true },
1617+
{ context: { isSystem: true } },
1618+
);
1619+
} else {
1620+
ctx.logger.warn('[auth] dev admin seeded but no row resolved for the email_verified stamp');
1621+
}
1622+
} catch (stampErr: any) {
1623+
// Fail-open on the stamp, fail-closed on elevation: an unstamped admin
1624+
// stays unverified and walled elevation refuses it loudly.
1625+
ctx.logger.warn(`[auth] dev admin email_verified stamp failed: ${stampErr?.message ?? stampErr}`);
1626+
}
15921627
ctx.logger.info(`🔑 Dev admin seeded: ${email} / ${password}`);
15931628
// Surface the credentials in the `serve` startup banner. The
15941629
// ctx.logger line above is swallowed by serve's boot-quiet window

packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts

Lines changed: 180 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
* (b) single: "first user is owner" is ruled reasonable and UNCHANGED — the
1919
* owner-email variable is never consulted there.
2020
*
21+
* [#11343] The walled match must additionally be VERIFIED: an email string is
22+
* not identity, so an account holding the owner's address with
23+
* `email_verified` unset/false is refused (`walled_owner_not_verified`).
24+
* BOTH directions of that invariant are pinned below — the unverified holder
25+
* is refused AND the verified owner is elevated (including across the
26+
* refuse-then-verify-then-re-run sequence the bootstrap-replay middleware
27+
* drives; its trigger set, `shouldReplayBootstrapFor`, is pinned here
28+
* beside it). A suite pinning only the refusal would score green on a
29+
* platform nobody can administer.
30+
*
2131
* The refusals here are bootstrap outcomes, not HTTP answers, so there is no
2232
* ADR-0112 envelope to assert; the machine-checkable surface is the exact
2333
* `reason` value plus the absence of any `sys_user_permission_set` write (the
@@ -26,7 +36,7 @@
2636

2737
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2838
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
29-
import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
39+
import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js';
3040

3141
/** In-memory ql over the three objects the promotion path touches. */
3242
function makeQl(seed: { users?: any[]; grants?: any[] } = {}) {
@@ -74,10 +84,16 @@ const adminFullAccess = () =>
7484

7585
const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() });
7686

77-
const user = (id: string, email: string, createdAt: string) => ({
87+
/**
88+
* [#11343] Rows carry `email_verified` explicitly where the case under test
89+
* depends on it. A row WITHOUT the field models an imported/legacy account —
90+
* which the elevation predicate deliberately reads as UNVERIFIED.
91+
*/
92+
const user = (id: string, email: string, createdAt: string, extra: Record<string, any> = {}) => ({
7893
id,
7994
email,
8095
created_at: createdAt,
96+
...extra,
8197
});
8298

8399
const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
@@ -106,7 +122,9 @@ describe('walled posture + declared owner — only the owner elevates', () => {
106122
const ql = makeQl({
107123
users: [
108124
user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z'),
109-
user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z'),
125+
// [#11343] The owner fixture is VERIFIED — this pin is about arrival
126+
// order, and it must keep holding under the verified-email invariant.
127+
user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true }),
110128
],
111129
});
112130
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
@@ -122,7 +140,9 @@ describe('walled posture + declared owner — only the owner elevates', () => {
122140
process.env.OS_TENANCY_POSTURE = 'isolated';
123141
process.env.OS_PLATFORM_OWNER_EMAIL = 'Operator@Corp.EXAMPLE';
124142
const ql = makeQl({
125-
users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z')],
143+
// [#11343] Verified — this pin is about case-insensitive matching, and
144+
// it must keep holding under the verified-email invariant.
145+
users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true })],
126146
});
127147
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
128148
expect(r.adminPromoted).toBe(true);
@@ -232,4 +252,160 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA
232252
expect(r.adminPromoted).toBe(true);
233253
expect(ql.grants()[0]?.user_id).toBe('u_first');
234254
});
255+
256+
it('an UNVERIFIED first user is still promoted under `single` — the verified invariant is walled-only', async () => {
257+
// [#11343] Over-denial guard: the ruling restored the invariant on the
258+
// WALLED owner match. `single` posture (the dev/seed-admin flow, where
259+
// verification is typically not wired at all) keeps first-user promotion
260+
// exactly as ruled reasonable in #11184.
261+
const ql = makeQl({
262+
users: [user('u_first', 'first@corp.example', '2026-08-23T01:00:00Z', { email_verified: false })],
263+
});
264+
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
265+
expect(r.adminPromoted).toBe(true);
266+
expect(ql.grants()[0]?.user_id).toBe('u_first');
267+
});
268+
});
269+
270+
// ───────────────────────────────────────────────────────────────────────────
271+
// [#11343] Walled elevation requires the owner-email match to be VERIFIED.
272+
// Both directions on purpose: refusal alone would score green on a platform
273+
// nobody can administer.
274+
// ───────────────────────────────────────────────────────────────────────────
275+
describe('walled posture — the owner-email match must be VERIFIED (#11343)', () => {
276+
beforeEach(() => {
277+
process.env.OS_TENANCY_POSTURE = 'isolated';
278+
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
279+
});
280+
281+
it('refuses an account holding the owner email with email_verified:false — the exact sign-up shape — and writes NO grant', async () => {
282+
// The path this card closes: someone registers with the declared owner's
283+
// address before the owner does. better-auth stores `email_verified:false`
284+
// at email/password sign-up, so this row is exactly what that registration
285+
// produces.
286+
const log = logger();
287+
const ql = makeQl({
288+
users: [user('u_squatter', 'operator@corp.example', '2026-08-23T01:00:00Z', { email_verified: false })],
289+
});
290+
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log });
291+
expect(r.adminPromoted).toBe(false);
292+
expect(r.reason).toBe('walled_owner_not_verified');
293+
expect(ql.grants()).toHaveLength(0);
294+
// Loud, at warn, and the message names the variable and the unblock (verify).
295+
expect(log.warn).toHaveBeenCalledTimes(1);
296+
expect(String(log.warn.mock.calls[0][0])).toContain('OS_PLATFORM_OWNER_EMAIL');
297+
expect(String(log.warn.mock.calls[0][0])).toContain('NOT VERIFIED');
298+
});
299+
300+
it('a row WITHOUT the email_verified field (imported/legacy) reads as unverified — absent is never verified', async () => {
301+
const ql = makeQl({
302+
users: [user('u_legacy', 'operator@corp.example', '2026-08-23T01:00:00Z')],
303+
});
304+
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
305+
expect(r.adminPromoted).toBe(false);
306+
expect(r.reason).toBe('walled_owner_not_verified');
307+
expect(ql.grants()).toHaveLength(0);
308+
});
309+
310+
it('elevates the verified owner — including on the re-run AFTER the verifying update (the exact sequence the replay middleware drives)', async () => {
311+
// First boot: the owner registered but has not clicked the link yet —
312+
// refused, no grant. Then the verification UPDATE lands on the row and the
313+
// bootstrap re-runs (in production: the replay middleware fires on that
314+
// update). Second run: elevated. Pinning the sequence, not just the end
315+
// state, proves the refusal is transient for the genuine owner.
316+
const ql = makeQl({
317+
users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: false })],
318+
});
319+
const first = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
320+
expect(first.adminPromoted).toBe(false);
321+
expect(first.reason).toBe('walled_owner_not_verified');
322+
expect(ql.grants()).toHaveLength(0);
323+
324+
// The verifying write better-auth issues when the link is clicked.
325+
await ql.update('sys_user', { id: 'u_owner', email_verified: true });
326+
327+
const second = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
328+
expect(second.adminPromoted).toBe(true);
329+
const grants = ql.grants();
330+
expect(grants).toHaveLength(1);
331+
expect(grants[0].user_id).toBe('u_owner');
332+
expect(grants[0].organization_id).toBeNull();
333+
});
334+
335+
it("accepts a driver's 1 as verified and 0 as unverified (SQLite boolean representation)", async () => {
336+
const refused = makeQl({
337+
users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: 0 })],
338+
});
339+
expect((await bootstrapPlatformAdmin(refused as any, [adminFullAccess()], { logger: logger() })).reason).toBe(
340+
'walled_owner_not_verified',
341+
);
342+
expect(refused.grants()).toHaveLength(0);
343+
344+
const elevated = makeQl({
345+
users: [user('u_owner', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: 1 })],
346+
});
347+
expect((await bootstrapPlatformAdmin(elevated as any, [adminFullAccess()], { logger: logger() })).adminPromoted).toBe(
348+
true,
349+
);
350+
expect(elevated.grants()[0]?.user_id).toBe('u_owner');
351+
});
352+
353+
it('two rows hold the owner email: the VERIFIED one is elevated even when the unverified one is older', async () => {
354+
// Arrival order decided ties before #11343; verification outranks it now.
355+
// (Two rows with one email is an imported/legacy shape — sign-up enforces
356+
// uniqueness — but the elevation must still never land on the unverified
357+
// row.)
358+
const ql = makeQl({
359+
users: [
360+
user('u_unverified_older', 'operator@corp.example', '2026-08-23T01:00:00Z', { email_verified: false }),
361+
user('u_verified_newer', 'operator@corp.example', '2026-08-23T02:00:00Z', { email_verified: true }),
362+
],
363+
});
364+
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
365+
expect(r.adminPromoted).toBe(true);
366+
const grants = ql.grants();
367+
expect(grants).toHaveLength(1);
368+
expect(grants[0].user_id).toBe('u_verified_newer');
369+
});
370+
});
371+
372+
// ───────────────────────────────────────────────────────────────────────────
373+
// [#11343] The bootstrap-replay trigger set. Email verification is an UPDATE,
374+
// so an insert-only replay would refuse the unverified owner at sign-up and
375+
// never look again — these pins are the "verified owner IS elevated" half at
376+
// the middleware seam. security-plugin.ts consumes this same predicate.
377+
// ───────────────────────────────────────────────────────────────────────────
378+
describe('shouldReplayBootstrapFor — bootstrap-replay trigger set (#11343)', () => {
379+
it('fires on sys_user insert/create (the original trigger, unchanged)', () => {
380+
expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'insert', data: { email: 'a@b.c' } })).toBe(true);
381+
expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'create', data: { email: 'a@b.c' } })).toBe(true);
382+
});
383+
384+
it('fires on a sys_user update touching email_verified — the verifying write', () => {
385+
expect(
386+
shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email_verified: true } }),
387+
).toBe(true);
388+
});
389+
390+
it('fires on a sys_user update touching email — the change-email write can newly match the declared owner', () => {
391+
expect(
392+
shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', email: 'x@y.z' } }),
393+
).toBe(true);
394+
});
395+
396+
it('does NOT fire on a sys_user update touching neither elevation column (profile edits must not re-run bootstrap)', () => {
397+
expect(
398+
shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update', data: { id: 'u1', name: 'New Name' } }),
399+
).toBe(false);
400+
});
401+
402+
it('does NOT fire for other objects, other operations, or a payload-less update', () => {
403+
expect(shouldReplayBootstrapFor({ object: 'task', operation: 'insert', data: {} })).toBe(false);
404+
expect(
405+
shouldReplayBootstrapFor({ object: 'task', operation: 'update', data: { email_verified: true } }),
406+
).toBe(false);
407+
expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'delete', data: { id: 'u1' } })).toBe(false);
408+
expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'find' })).toBe(false);
409+
expect(shouldReplayBootstrapFor({ object: 'sys_user', operation: 'update' })).toBe(false);
410+
});
235411
});

0 commit comments

Comments
 (0)