Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/walled-elevation-verified-email.md
Original file line numberDiff line numberDiff line change
@@ -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.
35 changes: 35 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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[] } = {}) {
Expand DownExpand Up@@ -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<string, any> = {}) => ({
id,
email,
created_at: createdAt,
...extra,
});

const OLD_POSTURE = process.env.OS_TENANCY_POSTURE;
Expand DownExpand Up@@ -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() });
Expand All@@ -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);
Expand DownExpand Up@@ -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);
});
});
Loading
Loading