Skip to content

Commit 4f65837

Browse files
os-steveclaude
andauthored
feat(plugin-auth): platform-admin re-anchor L3 — re-point ensureDefaultOrganization to the config anchor; re-price last-admin-guard as its own reviewed step (#13685)
* feat(plugin-auth): re-point ensureDefaultOrganization at the config anchor; move its trigger to the sys_user trigger set (L3) Design #11663 §2 step 5 / H4, ruled bundle 4A. The population question reads the config anchor first (matchesConfiguredPlatformAdmin from @objectstack/core — the derivation site's own predicate, no second derivation site) and falls back to the legacy unscoped admin_full_access grant (Choice 4A single-posture anchor + P5 honoured window, removed with migration step 6). The re-run trigger is the exported isDefaultOrganizationBootstrapTrigger: sys_user insert/create, sys_user update touching email/email_verified (the #11343 trigger set), plus the legacy grant-insert arm unchanged. No guard refusal is added or deleted in this commit — the last-admin-guard re-pricing is its own reviewed step (migration step 5), landing separately. Part of #11973 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * docs(plugin-auth): re-price last-admin-guard under the config anchor; retire the elevation framing from the walled-owner prose surfaces (L3 step 5) Migration step 5 of #11663 — its OWN reviewed change, separate from the re-pointing commit as the card requires. No refusal is added or deleted in code: every refusal is the output of the one resolveAdminUserIds enumeration, which has counted config-anchored administrators since L2, so pricing the population re-priced the refusals mechanically. This commit makes that a measured, pinned verdict: - OBSOLETE where the config anchor stands (pinned PERMITTED): delete / rename / deactivate of the admin_full_access sys_permission_set row, and deletion of the last legacy grant row, while a declared VERIFIED administrator stands. - KEPT where the grant anchor is load-bearing (pinned REFUSED): the identical four writes with no declared administrators (Choice 4A single-posture shape), and with a declared-but-UNVERIFIED account. - UNCHANGED: the zero-population tri-state (refuseIfEmptiedRatherThanFresh) — reachable only when no anchor stands, made rarer, never wronger; and the L2 fifth write shape (the newly-necessary refusals), verified as landed. Prose true-ups the L4 landing flagged to this card: the retired walled_owner_not_verified / elevation framing in walled-owner-verification-path.ts (semantic unchanged — an unverified declared address resolves non-admin, now at the derivation site) and the retired shouldReplayBootstrapFor create-arm coupling in walled-owner-operator-stamp.ts. Part of #11973 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * docs(permissions): re-anchor system-context census lines moved by the L3 diff Mechanical: node scripts/check-system-context-census.mjs --fix rewrote two line anchors (auth-plugin.ts:1288 -> 1296, last-admin-guard.ts:273 -> 286) that my comment-only insertions above them had rotted. Gate green after. Part of #11973 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99d23b1 commit 4f65837

11 files changed

Lines changed: 751 additions & 109 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
Re-point the default-organization bootstrap at the platform-admin config anchor (#11973, #11663 leg L3). `ensureDefaultOrganization` now resolves "which user is the platform admin" from `OS_PLATFORM_OWNER_EMAIL` first — the first declared entry with a stored, email-verified `sys_user` account, matched through `@objectstack/core`'s own `matchesConfiguredPlatformAdmin`, the same oracle the authorization derivation reads — and falls back to the legacy unscoped `admin_full_access` grant row (which still anchors `single`-posture deployments and the honoured migration window). Its re-run trigger widens from `sys_user_permission_set` inserts to the new exported predicate `isDefaultOrganizationBootstrapTrigger`: `sys_user` inserts and `email`/`email_verified` updates (how a config-anchored administrator comes into standing — on fresh walled rigs no grant insert ever fires any more), plus the legacy grant-insert arm unchanged. `single`-posture behaviour is unchanged: with the variable unset, the config half costs no read and the grant anchor decides exactly as before.

content/docs/permissions/system-context.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3827` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1288` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1296` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
@@ -198,7 +198,7 @@ assuming `isSystem` covers it is a documented source of bugs.
198198
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9492``9509` |
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1451` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201-
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:273` |
201+
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
202202
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
203203

204204
---

packages/plugins/plugin-auth/src/auth-plugin-walled-owner-verification-path.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
* A, verbatim 「全部同意」).
77
*
88
* ⛔ Nothing here may become a refusal: boot proceeds in EVERY shape below,
9-
* including the one that warns. The two refusals around this check
10-
* (`walled_owner_email_undeclared` at boot, `walled_owner_not_verified` at
11-
* elevation) are pinned by their own suites and are untouched.
9+
* including the one that warns. The fail-closed clauses around this check —
10+
* the `walled_owner_email_undeclared` boot refusal, and ([#11973] since the
11+
* #11663 L4 re-anchor) the derivation site resolving an unverified declared
12+
* address non-admin per request — are pinned by their own suites and are
13+
* untouched.
1214
*
1315
* The load-bearing half of this file is the CONTROLS. A warning that fires on
1416
* every boot satisfies "the dead-end shape warns" just as well as a correct
@@ -110,8 +112,12 @@ describe('#11640 — the dead-end shape warns, by name and with the remedy', ()
110112
// exactly here.
111113
expect(msg).toContain('Either one alone clears this');
112114
// …and it says what goes wrong if nothing is wired, in the vocabulary of
113-
// the refusal the owner will actually hit.
114-
expect(msg).toContain('walled_owner_not_verified');
115+
// the dead end the owner will actually hit ([#11973]: no elevation
116+
// refusal exists post-L4 — an unverified declared address simply resolves
117+
// no standing at the derivation site).
118+
expect(msg).toContain('NO platform-admin standing');
119+
expect(msg).toContain('derived at request time');
120+
expect(msg).not.toContain('walled_owner_not_verified');
115121
});
116122

117123
it('⛔ it is a WARNING, never a refusal — the text promises boot continues', () => {
@@ -287,7 +293,10 @@ describe('#12751 — the warning follows the owner account state', () => {
287293
const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified'));
288294
expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH);
289295
expect(msg).toContain('ALREADY EXISTS');
290-
expect(msg).toContain('walled_owner_not_verified');
296+
// [#11973] The dead end in the derivation's own vocabulary — the retired
297+
// elevation refusal token must be gone.
298+
expect(msg).toContain('NO platform-admin standing');
299+
expect(msg).not.toContain('walled_owner_not_verified');
291300
});
292301

293302
it('a populated store with NO owner account warns — the bootstrap window is spent and an invitee arrives unverified', () => {

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,27 @@ describe('AuthPlugin', () => {
10851085
expect(ql.insert).not.toHaveBeenCalled();
10861086
});
10871087

1088+
// [#11973 / #11663 L3] The trigger set widened to the #11343 `sys_user`
1089+
// arms: a config-anchored administrator comes into standing through a
1090+
// `sys_user` insert (operator-provisioned, arrives verified) or a
1091+
// verifying/email update — with no grant insert ever firing post-L4.
1092+
it('single-org: re-runs after a sys_user insert and after an email_verified update (#11973)', async () => {
1093+
await boot();
1094+
const runAll = async (opCtx: any) => {
1095+
for (const mw of middlewares) await mw(opCtx, async () => {});
1096+
};
1097+
await runAll({ object: 'sys_user', operation: 'insert' });
1098+
expect(ql.tables.sys_member).toHaveLength(1);
1099+
// The verifying update fires the bootstrap too (idempotent second pass).
1100+
await runAll({ object: 'sys_user', operation: 'update', data: { email_verified: true } });
1101+
expect(ql.tables.sys_member).toHaveLength(1);
1102+
// A sys_user update touching NEITHER standing column costs no run at
1103+
// all — asserted on `find`, which any fired pass must call first.
1104+
ql.find.mockClear();
1105+
await runAll({ object: 'sys_user', operation: 'update', data: { name: 'renamed' } });
1106+
expect(ql.find).not.toHaveBeenCalled();
1107+
});
1108+
10881109
it('single-org: idempotent — second kernel:ready pass is a no-op', async () => {
10891110
await boot();
10901111
await hookCapture.trigger('kernel:ready');

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ import {
3232
readMcpServerEnabledEnv,
3333
type AuthManagerOptions,
3434
} from './auth-manager.js';
35-
import { ensureDefaultOrganization } from './ensure-default-organization.js';
35+
import {
36+
ensureDefaultOrganization,
37+
isDefaultOrganizationBootstrapTrigger,
38+
} from './ensure-default-organization.js';
3639
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
3740
import { runAttributedToUser } from './auth-actor-attribution.js';
3841
import type { AuthEventAuditSurface } from './auth-session-audit.js';
@@ -181,10 +184,13 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
181184
* organization, so sessions carry no `activeOrganizationId` and better-auth
182185
* `organization/invite-member` has no org to resolve — i.e. no way to add a
183186
* user at all. When enabled (default), the plugin idempotently creates the
184-
* `Default Organization` (slug `default`) and binds the first platform
185-
* admin as `owner`, on `kernel:ready` and after every
186-
* `sys_user_permission_set` insert. Inert in multi-org mode — the
187-
* enterprise organizations package owns the bootstrap there.
187+
* `Default Organization` (slug `default`) and binds the platform admin as
188+
* `owner`, on `kernel:ready` and after every write matched by
189+
* `isDefaultOrganizationBootstrapTrigger` (a `sys_user` insert or
190+
* email/email_verified update — the config-anchor trigger set — plus the
191+
* legacy `sys_user_permission_set` insert that `single`-posture first-user
192+
* promotion still writes). Inert in multi-org mode — the enterprise
193+
* organizations package owns the bootstrap there.
188194
* @default true
189195
*/
190196
autoDefaultOrganization?: boolean;
@@ -1047,18 +1053,20 @@ export class AuthPlugin implements Plugin {
10471053
}
10481054
};
10491055
ctx.hook('kernel:ready', runEnsure);
1050-
// Re-run after every admin grant — covers the "first sign-up promoted
1051-
// to platform admin" case where kernel:ready fired before any user
1052-
// existed (same wiring the multi-org bootstrap uses).
1056+
// [#11973 / #11663 L3] Re-run after every write that can move the
1057+
// population answer, judged by the ONE exported trigger predicate: a
1058+
// `sys_user` insert or email/email_verified update (the #11343 trigger
1059+
// set — how a CONFIG-anchored admin comes into standing), and the
1060+
// legacy `sys_user_permission_set` insert (how `single`-posture
1061+
// first-user promotion lands standing, Choice 4A — retired with the
1062+
// legacy-grant removal leg). The enterprise organizations package's
1063+
// walled wiring should consume the same predicate.
10531064
try {
10541065
const ql = ctx.getService<IObjectQLEngine>('objectql');
10551066
if (ql && typeof ql.registerMiddleware === 'function') {
10561067
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
10571068
await next();
1058-
if (
1059-
opCtx?.object === 'sys_user_permission_set' &&
1060-
(opCtx?.operation === 'insert' || opCtx?.operation === 'create')
1061-
) {
1069+
if (isDefaultOrganizationBootstrapTrigger(opCtx)) {
10621070
await runEnsure();
10631071
}
10641072
});

packages/plugins/plugin-auth/src/ensure-default-organization.test.ts

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,34 @@
44
// Covers the idempotency short-circuits, the create/reuse paths, and the
55
// injectable seed-ownership step (enterprise injects it; open path omits it).
66

7-
import { describe, it, expect, vi } from 'vitest';
8-
import { ensureDefaultOrganization } from './ensure-default-organization.js';
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { resetPlatformAdminEmailMemo } from '@objectstack/core';
9+
import {
10+
ensureDefaultOrganization,
11+
isDefaultOrganizationBootstrapTrigger,
12+
} from './ensure-default-organization.js';
13+
14+
// [#11973] The config anchor reads `OS_PLATFORM_OWNER_EMAIL` live (memoized on
15+
// the raw value), so every case in this file pins the variable's state instead
16+
// of inheriting the ambient environment's.
17+
const ENV = 'OS_PLATFORM_OWNER_EMAIL';
18+
let ambientOwnerEmail: string | undefined;
19+
beforeEach(() => {
20+
ambientOwnerEmail = process.env[ENV];
21+
delete process.env[ENV];
22+
resetPlatformAdminEmailMemo();
23+
});
24+
afterEach(() => {
25+
if (ambientOwnerEmail === undefined) delete process.env[ENV];
26+
else process.env[ENV] = ambientOwnerEmail;
27+
resetPlatformAdminEmailMemo();
28+
});
29+
30+
/** Declare the deployment's administrators and drop the raw-value memo. */
31+
function declare(value: string): void {
32+
process.env[ENV] = value;
33+
resetPlatformAdminEmailMemo();
34+
}
935

1036
type Row = Record<string, any>;
1137

@@ -17,6 +43,7 @@ function makeQl(seed: Partial<Record<string, Row[]>> = {}) {
1743
],
1844
sys_member: [],
1945
sys_organization: [],
46+
sys_user: [],
2047
...seed,
2148
};
2249
const matches = (row: Row, where: Row) =>
@@ -179,4 +206,130 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => {
179206
expect(sink.seen).toHaveLength(1);
180207
});
181208
});
209+
210+
// [#11973 / #11663 L3] The config-anchored population — design §2 step 5.
211+
describe('config-anchored population (#11973)', () => {
212+
const OWNER = 'owner@corp.example';
213+
214+
it('finds a declared, VERIFIED administrator with NO grant row anywhere (post-L4 walled population)', async () => {
215+
declare(OWNER);
216+
const ql = makeQl({
217+
sys_user_permission_set: [],
218+
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }],
219+
});
220+
const res = await ensureDefaultOrganization(ql);
221+
expect(res.memberCreated).toBe(true);
222+
expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u_cfg', role: 'owner' });
223+
});
224+
225+
it('prefers the config anchor over the legacy grant anchor (the derivation prefers config)', async () => {
226+
declare(OWNER);
227+
const ql = makeQl({
228+
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }],
229+
});
230+
// The default fixture also carries the legacy grant admin `u1`.
231+
await ensureDefaultOrganization(ql);
232+
expect(ql.tables.sys_member[0].user_id).toBe('u_cfg');
233+
});
234+
235+
it('an UNVERIFIED declared account confers nothing — falls back to the legacy grant anchor', async () => {
236+
declare(OWNER);
237+
const ql = makeQl({
238+
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: false }],
239+
});
240+
await ensureDefaultOrganization(ql);
241+
expect(ql.tables.sys_member[0].user_id).toBe('u1');
242+
});
243+
244+
it('declared but nobody registered, and no grants: no_admin — the trigger set re-runs it later', async () => {
245+
declare(OWNER);
246+
const ql = makeQl({ sys_user_permission_set: [] });
247+
const res = await ensureDefaultOrganization(ql);
248+
expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' });
249+
});
250+
251+
it('operator order decides between several declared administrators with standing', async () => {
252+
declare('first@corp.example,second@corp.example');
253+
const ql = makeQl({
254+
sys_user_permission_set: [],
255+
sys_user: [
256+
{ id: 'u_second', email: 'second@corp.example', email_verified: true },
257+
{ id: 'u_first', email: 'first@corp.example', email_verified: true },
258+
],
259+
});
260+
await ensureDefaultOrganization(ql);
261+
expect(ql.tables.sys_member[0].user_id).toBe('u_first');
262+
});
263+
264+
it('an entry with no verified account is passed over for the next declared entry', async () => {
265+
declare('first@corp.example,second@corp.example');
266+
const ql = makeQl({
267+
sys_user_permission_set: [],
268+
sys_user: [{ id: 'u_second', email: 'second@corp.example', email_verified: true }],
269+
});
270+
await ensureDefaultOrganization(ql);
271+
expect(ql.tables.sys_member[0].user_id).toBe('u_second');
272+
});
273+
274+
it('a REFUSED variable (unparseable entry) fails the whole list closed — legacy anchor answers', async () => {
275+
declare(`${OWNER},not an email`);
276+
const ql = makeQl({
277+
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }],
278+
});
279+
await ensureDefaultOrganization(ql);
280+
// Choice 2B: the whole variable is refused, never the one entry — so the
281+
// verified declared account confers nothing and the grant admin is bound.
282+
expect(ql.tables.sys_member[0].user_id).toBe('u1');
283+
});
284+
285+
it('queries the VERBATIM spelling too — an imported row that is not stored lowercased is found', async () => {
286+
declare('Ada@Example.com');
287+
const ql = makeQl({
288+
sys_user_permission_set: [],
289+
// The fake driver is an exact-match store, so the normalized
290+
// (lowercased) lookup misses this row; only the as-typed spelling hits.
291+
sys_user: [{ id: 'u_ada', email: 'Ada@Example.com', email_verified: true }],
292+
});
293+
await ensureDefaultOrganization(ql);
294+
expect(ql.tables.sys_member[0].user_id).toBe('u_ada');
295+
});
296+
297+
// The Choice 4A pin the PM asked for by name: with the variable UNSET, a
298+
// verified `sys_user` row is NOT a population candidate. If the re-point
299+
// leaked into the `single` branch (any verified user read as an admin
300+
// candidate), `u_other` would win the bind below and this goes red.
301+
it('config UNSET: a verified sys_user row is NOT an admin candidate — the grant anchor decides (Choice 4A)', async () => {
302+
const ql = makeQl({
303+
sys_user: [{ id: 'u_other', email: 'other@corp.example', email_verified: true }],
304+
});
305+
const res = await ensureDefaultOrganization(ql);
306+
expect(res.memberCreated).toBe(true);
307+
expect(ql.tables.sys_member[0].user_id).toBe('u1');
308+
// …and the config half cost no sys_user read at all.
309+
expect(ql.find).not.toHaveBeenCalledWith('sys_user', expect.anything(), expect.anything());
310+
});
311+
});
312+
});
313+
314+
// [#11973 / #11663 L3, design H4] The trigger predicate — one definition for
315+
// every wiring (plugin-auth's middleware here; the enterprise organizations
316+
// package's walled wiring is asked to consume the same export).
317+
describe('isDefaultOrganizationBootstrapTrigger', () => {
318+
it.each([
319+
[{ object: 'sys_user', operation: 'insert' }, true],
320+
[{ object: 'sys_user', operation: 'create' }, true],
321+
[{ object: 'sys_user', operation: 'update', data: { email_verified: true } }, true],
322+
[{ object: 'sys_user', operation: 'update', data: { email: 'x@y.example' } }, true],
323+
[{ object: 'sys_user', operation: 'update', data: { name: 'renamed' } }, false],
324+
[{ object: 'sys_user', operation: 'update' }, false],
325+
[{ object: 'sys_user', operation: 'delete' }, false],
326+
[{ object: 'sys_user_permission_set', operation: 'insert' }, true],
327+
[{ object: 'sys_user_permission_set', operation: 'create' }, true],
328+
[{ object: 'sys_user_permission_set', operation: 'update', data: { organization_id: null } }, false],
329+
[{ object: 'sys_member', operation: 'insert' }, false],
330+
[{ object: 'task', operation: 'insert' }, false],
331+
[{}, false],
332+
])('%j → %s', (opCtx, expected) => {
333+
expect(isDefaultOrganizationBootstrapTrigger(opCtx as any)).toBe(expected);
334+
});
182335
});

0 commit comments

Comments
 (0)