Skip to content

Commit fd3dc2f

Browse files
committed
test(plugin-security,plugin-auth): pins for the grant that names nothing, and fixture names
The by-name readers' fail-closed treatment of a grant whose name column is empty: explain reports nothing, the bootstrap withholds the promotion and names no holder, the reconcile still revokes through the id, the delegated gate refuses the pre-image. Grant fixtures that bypass the engine's name hooks now carry the name every platform writer stores. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e2b5cf commit fd3dc2f

11 files changed

Lines changed: 521 additions & 19 deletions
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D4] The grant that names nothing, as each `plugin-auth` grant
5+
* reader treats it once the readers key on `sys_user_permission_set.permission_set`.
6+
*
7+
* A grant written before the name column existed carries `NULL` until the
8+
* one-time backfill (`plugin-security`, `kernel:bootstrapped`) names it; on an
9+
* upgraded deployment's first boot every grant is unnamed when the
10+
* `kernel:ready` passes run, and the backfill leaves some unnamed for good (an
11+
* id with no set row, or a set row of another organization). Each reader takes
12+
* its fail-closed direction:
13+
*
14+
* - **the last-administrator guard** counts no administrator through it, and
15+
* reads it as evidence that the environment is NOT fresh — so the bootstrap
16+
* window never opens on an environment whose administrator holds one;
17+
* - **the default-organization bootstrap** does not read it as the platform
18+
* administrator: it confers an owner membership, and an unnamed grant
19+
* confers nothing.
20+
*
21+
* And the guard's simulation of a write that re-points a grant's id without
22+
* its name: the platform derives that name after the guard runs, so the guard
23+
* reads it as taking the standing away — while an id echoed unchanged keeps it.
24+
*
25+
* Real ObjectQL over the SQL driver with the real `SecurityPlugin` (its objects
26+
* and its name hooks); a grant is unnamed by writing `NULL` with the name hooks
27+
* unbound, the state the backfill leaves.
28+
*/
29+
30+
import { describe, it, expect, afterEach, vi } from 'vitest';
31+
import { ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { resetPlatformAdminEmailMemo } from '@objectstack/core';
34+
import { SecurityPlugin, bootstrapPlatformAdmin } from '@objectstack/plugin-security';
35+
import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity';
36+
37+
import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js';
38+
import { ensureDefaultOrganization } from './ensure-default-organization.js';
39+
40+
const SYS = { context: { isSystem: true } } as any;
41+
const NAME_HOOKS = 'plugin-security:grant-permission-set-name';
42+
43+
const engines: ObjectQL[] = [];
44+
afterEach(async () => {
45+
vi.restoreAllMocks();
46+
resetPlatformAdminEmailMemo();
47+
while (engines.length) {
48+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
49+
}
50+
});
51+
52+
interface Rig {
53+
engine: ObjectQL;
54+
/** Re-bind the plugin's name hooks after `unname` (a fresh start of the plugin's hook half). */
55+
plugin: SecurityPlugin;
56+
}
57+
58+
/** `single` posture; the first user is promoted to platform administrator — the sole administrator. */
59+
async function soleGrantAnchoredAdmin(): Promise<Rig> {
60+
resetPlatformAdminEmailMemo();
61+
const engine = new ObjectQL();
62+
engine.registerDriver(
63+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
64+
true,
65+
);
66+
await engine.init();
67+
engines.push(engine);
68+
let manifest: { objects?: unknown[]; permissions?: unknown[] } = {};
69+
const services: Record<string, unknown> = {
70+
manifest: { register: (m: any) => { manifest = m; } },
71+
objectql: engine,
72+
metadata: {
73+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
74+
list: async () => [...((manifest.permissions as unknown[]) ?? [])],
75+
},
76+
};
77+
const ctx: any = {
78+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
79+
hook: vi.fn(),
80+
registerService: vi.fn(),
81+
getService: (name: string) => {
82+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
83+
return services[name];
84+
},
85+
};
86+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
87+
await plugin.init(ctx);
88+
engine.registerApp({
89+
id: 'com.objectstack.qa.grant-readers-unnamed-auth',
90+
name: 'Grant readers — unnamed grant (auth)',
91+
version: '1.0.0',
92+
type: 'plugin',
93+
scope: 'system',
94+
objects: [
95+
SysUser, SysAccount, SysMember, SysOrganization,
96+
...(manifest.objects ?? []).filter((o: any) =>
97+
['sys_position', 'sys_user_position', 'sys_permission_set', 'sys_position_permission_set', 'sys_user_permission_set']
98+
.includes(o?.name)),
99+
],
100+
} as any);
101+
await engine.syncSchemas();
102+
await plugin.start(ctx);
103+
vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined);
104+
registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, {
105+
packageId: 'test.grant-readers-unnamed',
106+
logger: { info: () => undefined, warn: () => undefined },
107+
});
108+
for (const [id, createdAt] of [['usr_admin', '2025-01-01T00:00:00.000Z'], ['usr_member', '2025-03-01T00:00:00.000Z']]) {
109+
await engine.insert(
110+
'sys_user', { id, email: `${id}@un.example`, name: id, created_at: createdAt, email_verified: true, banned: false }, SYS,
111+
);
112+
await engine.insert(
113+
'sys_account', { id: `acc_${id}`, user_id: id, account_id: `${id}@un.example`, provider_id: 'credential' }, SYS,
114+
);
115+
}
116+
const boot = await bootstrapPlatformAdmin(engine, (manifest.permissions ?? []) as any[]);
117+
expect(boot).toMatchObject({ adminPromoted: true });
118+
return { engine, plugin };
119+
}
120+
121+
const adminGrant = async (engine: ObjectQL): Promise<any> =>
122+
((await engine.find('sys_user_permission_set', { where: { user_id: 'usr_admin' }, ...SYS })) as any[])[0];
123+
124+
async function unnameAdminGrant(engine: ObjectQL): Promise<void> {
125+
(engine as any).unregisterHooksByPackage(NAME_HOOKS);
126+
await engine.update('sys_user_permission_set', { permission_set: null }, { where: { user_id: 'usr_admin' }, multi: true, ...SYS });
127+
expect((await adminGrant(engine)).permission_set ?? null).toBeNull();
128+
}
129+
130+
const ban = (engine: ObjectQL, id: string) => engine.update('sys_user', { id, banned: true }, SYS);
131+
132+
describe('[ADR-0131 D4] a grant that names nothing — the last-administrator guard', () => {
133+
it('control — named, the grant is the last administrator: banning its holder is refused', async () => {
134+
const { engine } = await soleGrantAnchoredAdmin();
135+
expect((await adminGrant(engine)).permission_set).toBe('admin_full_access');
136+
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/last administrator/i);
137+
});
138+
139+
it('unnamed, it counts no administrator but is evidence: the bootstrap window does not open', async () => {
140+
const { engine } = await soleGrantAnchoredAdmin();
141+
await unnameAdminGrant(engine);
142+
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/not the bootstrap window/i);
143+
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/no permission-set name yet/);
144+
await expect(ban(engine, 'usr_member')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
145+
});
146+
147+
it('re-pointing the sole administrator’s grant by id is read as taking the standing away', async () => {
148+
const { engine } = await soleGrantAnchoredAdmin();
149+
const grant = await adminGrant(engine);
150+
const [viewer] = (await engine.find('sys_permission_set', { where: { name: 'viewer_readonly' }, ...SYS })) as any[];
151+
await expect(engine.update('sys_user_permission_set', { id: grant.id, permission_set_id: viewer.id }, SYS))
152+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
153+
expect((await adminGrant(engine)).permission_set).toBe('admin_full_access');
154+
});
155+
156+
it('echoing the grant’s own id keeps the standing: the write is permitted', async () => {
157+
const { engine } = await soleGrantAnchoredAdmin();
158+
const grant = await adminGrant(engine);
159+
await engine.update(
160+
'sys_user_permission_set',
161+
{ id: grant.id, permission_set_id: grant.permission_set_id, reason: 'renewed' },
162+
SYS,
163+
);
164+
expect((await adminGrant(engine)).reason).toBe('renewed');
165+
});
166+
});
167+
168+
describe('[ADR-0131 D4] a grant that names nothing — the default-organization bootstrap', () => {
169+
it('control — named, the grant holder is bound as the Default Organization owner', async () => {
170+
const { engine } = await soleGrantAnchoredAdmin();
171+
const res = await ensureDefaultOrganization(engine as any, { logger: { info: () => undefined, warn: () => undefined } });
172+
expect(res.memberCreated).toBe(true);
173+
});
174+
175+
it('unnamed, nobody is read as the platform administrator: `no_admin`, nothing is bound', async () => {
176+
const { engine } = await soleGrantAnchoredAdmin();
177+
await unnameAdminGrant(engine);
178+
const res = await ensureDefaultOrganization(engine as any, { logger: { info: () => undefined, warn: () => undefined } });
179+
expect(res).toMatchObject({ memberCreated: false, reason: 'no_admin' });
180+
expect(await engine.find('sys_member', { where: { user_id: 'usr_admin' }, ...SYS })).toEqual([]);
181+
});
182+
});

‎packages/plugins/plugin-security/src/auto-org-admin-grant.test.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,18 +1047,28 @@ describe('[#11670] `single` posture is carved out', () => {
10471047
});
10481048
});
10491049

1050-
it('the read ORDER and the objects read are unchanged under `single`', async () => {
1051-
// The rest of the multiset: same objects, same order, same predicates —
1052-
// only the two reads named in the deviation above differ, and only in
1053-
// `limit`/`$in`.
1050+
it('the read ORDER and the objects read under `single`', async () => {
1051+
// The rest of the multiset: same objects, same order — only the two reads
1052+
// named in the deviation above differ, and only in `limit`/`$in`.
1053+
//
1054+
// [ADR-0131 D4] Each grant question is now asked BY NAME, plus one read by
1055+
// id that reaches only the pair's grants that name nothing yet (written
1056+
// before the name column, not yet backfilled) — for the restriction alone.
10541057
const stub = seedSingle();
10551058
await reconcileOrgAdminGrant(stub, 'u1', 'o1', { posture: 'single' });
10561059
expect(stub.findCalls.map((c) => c.object)).toEqual([
10571060
'sys_permission_set', // grant-target resolution (limit 1, unchanged)
10581061
'sys_member', // does the pair qualify
10591062
'sys_permission_set', // superseded-variant ids (widened — see above)
1060-
'sys_user_permission_set', // superseded grants for the pair (widened)
1061-
'sys_user_permission_set', // does the grant already exist (scalar, unchanged)
1063+
'sys_user_permission_set', // superseded grants for the pair, by name
1064+
'sys_user_permission_set', // …and the pair's unnamed ones, by every copy's id
1065+
'sys_user_permission_set', // does the grant already exist, by name
1066+
'sys_user_permission_set', // …or an unnamed one carrying the id it would write
1067+
]);
1068+
const grantReads = stub.findCalls.filter((c) => c.object === 'sys_user_permission_set');
1069+
expect(grantReads.map((c) => c.where?.permission_set ?? null)).toEqual([
1070+
'organization_admin', null, 'organization_admin_no_bypass', null,
10621071
]);
1072+
expect(grantReads[3].where.permission_set_id).toEqual({ $in: ['ps_org_admin_nb'] });
10631073
});
10641074
});

‎packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ describe('#14348 — the promotion target is the oldest human that can AUTHENTIC
334334
id: 'ups_legacy',
335335
user_id: 'usr_person0',
336336
permission_set_id: adminPsId,
337+
permission_set: 'admin_full_access',
337338
organization_id: null,
338339
},
339340
{ context: SYSTEM_CTX },

‎packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ async function seedTenant(
239239
id: 'ups_zzz_founder',
240240
user_id: 'usr_founder',
241241
permission_set_id: ADMIN_PS_ID,
242+
permission_set: 'admin_full_access',
242243
organization_id: unscopedOrganizationId,
243244
},
244245
{ context: SYSTEM_CTX },
@@ -259,6 +260,7 @@ async function seedTenant(
259260
id: `ups_org_${n}`,
260261
user_id: `usr_orgadmin_${n}`,
261262
permission_set_id: ADMIN_PS_ID,
263+
permission_set: 'admin_full_access',
262264
organization_id: `org_${n}`,
263265
},
264266
{ context: SYSTEM_CTX },
@@ -419,7 +421,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () =
419421
);
420422
await ql.insert(
421423
'sys_user_permission_set',
422-
{ id: 'ups_system', user_id: 'usr_system', permission_set_id: ADMIN_PS_ID, organization_id: null },
424+
{ id: 'ups_system', user_id: 'usr_system', permission_set_id: ADMIN_PS_ID, permission_set: 'admin_full_access', organization_id: null },
423425
{ context: SYSTEM_CTX },
424426
);
425427
await seedUser(ql, 'usr_real', 'real@tenant.example', '2026-03-01T00:00:00.000Z', true);

‎packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,7 @@ describe('#16682 — the promotion target is chosen, not sampled', () => {
755755
id: 'ups_legacy',
756756
user_id: 'usr_person0',
757757
permission_set_id: sets[0].id,
758+
permission_set: 'admin_full_access',
758759
organization_id: null,
759760
},
760761
{ context: SYSTEM_CTX },

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5)
371371
makeQl({
372372
sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }],
373373
users: [user('u_legacy', 'legacy-admin@corp.example', '2026-01-01T00:00:00Z', { email_verified: true })],
374-
grants: [{ id: 'ups_1', user_id: 'u_legacy', permission_set_id: 'ps_admin', organization_id: null }],
374+
grants: [{ id: 'ups_1', user_id: 'u_legacy', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }],
375375
});
376376

377377
it('⛔ a seeded legacy grant produces NO deprecation line any more — the window is closed', async () => {
@@ -434,7 +434,7 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5)
434434
const log = logger();
435435
const ql = makeQl({
436436
sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }],
437-
grants: [{ id: 'ups_1', user_id: SystemUserId.SYSTEM, permission_set_id: 'ps_admin', organization_id: null }],
437+
grants: [{ id: 'ups_1', user_id: SystemUserId.SYSTEM, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }],
438438
});
439439
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log });
440440
expect(r.reason).toBe('walled_owner_email_undeclared');
@@ -540,7 +540,7 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA
540540
const ql = makeQl({
541541
sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }],
542542
users: [user('u_admin', 'admin@corp.example', '2026-08-23T01:00:00Z')],
543-
grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', organization_id: null }],
543+
grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }],
544544
});
545545
const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() });
546546
expect(r.adminPromoted).toBe(false);

‎packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', ()
281281
id: 'ups_existing',
282282
user_id: ADMIN,
283283
permission_set_id: 'ps_admin_full_access',
284+
permission_set: 'admin_full_access',
284285
organization_id: null,
285286
});
286287
rig.tables.sys_permission_set.push({ id: 'ps_admin_full_access', name: 'admin_full_access' });

‎packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,8 @@ describe('the claim target is the existing platform admin, by the bootstrap rule
309309
await engine.insert('sys_user', { id, email: `${id}@example.test`, name: id, created_at: createdAt }, SYS);
310310
await engine.insert('sys_account', { id: `acc_${id}`, user_id: id, account_id: id, provider_id: 'credential' }, SYS);
311311
}
312-
await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_zed', permission_set_id: 'ps_admin', organization_id: null }, SYS);
313-
await engine.insert('sys_user_permission_set', { id: 'ups_2', user_id: 'usr_amy', permission_set_id: 'ps_admin', organization_id: null }, SYS);
312+
await engine.insert('sys_user_permission_set', { id: 'ups_1', user_id: 'usr_zed', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, SYS);
313+
await engine.insert('sys_user_permission_set', { id: 'ups_2', user_id: 'usr_amy', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, SYS);
314314
return engine;
315315
}
316316

‎packages/plugins/plugin-security/src/delegated-admin-gate.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,52 @@ describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () =>
347347
context: h.ctxOf('delegate'),
348348
})).rejects.toThrow(/not strictly contained/);
349349
});
350+
351+
// [ADR-0131 D4] A stored grant's pre-image is judged by the set it NAMES
352+
// (`permission_set`), read from the catalog by that name; an insert, or an
353+
// update that re-points the id, is judged by the id it writes, as before.
354+
describe('[ADR-0131 D4] the pre-image is judged by the name the grant holds', () => {
355+
const storedGrant = (row: Record<string, unknown>) => {
356+
for (const set of h.tables.sys_permission_set) set.organization_id ??= null;
357+
h.tables.sys_user_permission_set = [{ id: 'g1', user_id: 'u_east_1', ...row }];
358+
};
359+
const remove = () => h.gate.assert({
360+
object: 'sys_user_permission_set', operation: 'delete',
361+
options: { where: { id: 'g1' } }, context: h.ctxOf('delegate'),
362+
});
363+
364+
it('a grant naming an allowlisted set may be revoked by the delegate — whatever its id says', async () => {
365+
storedGrant({ permission_set_id: 'ps_fin', permission_set: 'sales_user' });
366+
await expect(remove()).resolves.toBeUndefined();
367+
});
368+
369+
it('a grant naming a set outside the allowlist is refused — whatever its id says', async () => {
370+
storedGrant({ permission_set_id: 'ps_sales', permission_set: 'finance_admin' });
371+
await expect(remove()).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/);
372+
});
373+
374+
it('a grant that names NO set is refused: the delegate cannot be shown to hold authority over it', async () => {
375+
storedGrant({ permission_set_id: 'ps_sales', permission_set: null });
376+
await expect(remove()).rejects.toThrow(/names no permission set/);
377+
});
378+
379+
it('a grant naming a set no catalog row carries is refused', async () => {
380+
storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user_retired' });
381+
await expect(remove()).rejects.toThrow(/has no catalog row/);
382+
});
383+
384+
it('an update keeping the set is judged by the stored name; one re-pointing the id by the new id', async () => {
385+
storedGrant({ permission_set_id: 'ps_sales', permission_set: 'sales_user' });
386+
await expect(h.gate.assert({
387+
object: 'sys_user_permission_set', operation: 'update',
388+
data: { id: 'g1', reason: 'renewed' }, context: h.ctxOf('delegate'),
389+
})).resolves.toBeUndefined();
390+
await expect(h.gate.assert({
391+
object: 'sys_user_permission_set', operation: 'update',
392+
data: { id: 'g1', permission_set_id: 'ps_fin' }, context: h.ctxOf('delegate'),
393+
})).rejects.toThrow(/'finance_admin' is not in the scope's allowlist/);
394+
});
395+
});
350396
});
351397

352398
describe('DelegatedAdminGate — env-set authoring (sys_permission_set)', () => {

0 commit comments

Comments
 (0)