Skip to content

Commit fed07db

Browse files
committed
wip(auth): the guard's evidence reads and the default-org bootstrap follow the Default Organization's grant
Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude <noreply@anthropic.com>
1 parent edae1dd commit fed07db

9 files changed

Lines changed: 292 additions & 22 deletions

‎packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ async function rig(): Promise<ObjectQL> {
9898
],
9999
} as any);
100100
await engine.syncSchemas();
101+
// [ADR-0131 D3/D5] The Default Organization a `single` boot creates before
102+
// `kernel:ready` (AuthPlugin's invariant); it owns the promotion's grant.
103+
await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS);
101104
await security.start(ctx);
102105
vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined);
103106

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,40 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => {
154154
expect(res.memberCreated).toBe(true);
155155
});
156156

157+
it('[ADR-0131 D5] binds the holder of the Default Organization\'s admin grant — the row the single promotion writes', async () => {
158+
const ql = makeQl({
159+
sys_organization: [{ id: 'org_default', slug: 'default', name: 'Default Organization' }],
160+
sys_user_permission_set: [
161+
{ id: 'ups_d', user_id: 'u_promoted', permission_set: 'admin_full_access', organization_id: 'org_default' },
162+
],
163+
});
164+
const res = await ensureDefaultOrganization(ql);
165+
expect(res).toMatchObject({ defaultOrgCreated: false, defaultOrgId: 'org_default', memberCreated: true });
166+
expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'u_promoted', role: 'owner', organization_id: 'org_default' })]);
167+
});
168+
169+
it('[ADR-0131 D5] ⛔ another organization\'s admin grant is an organization administrator\'s, never the anchor', async () => {
170+
const ql = makeQl({
171+
sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_b', slug: 'b' }],
172+
sys_user_permission_set: [
173+
{ id: 'ups_b', user_id: 'u_org_admin', permission_set: 'admin_full_access', organization_id: 'org_b' },
174+
],
175+
});
176+
expect(await ensureDefaultOrganization(ql)).toMatchObject({ memberCreated: false, reason: 'no_admin' });
177+
});
178+
179+
it('[ADR-0131 D3/D5] a sole organization with another slug IS the Default Organization: the admin is bound there, no second one is minted', async () => {
180+
const ql = makeQl({
181+
sys_organization: [{ id: 'org_acme', slug: 'acme', name: 'Acme' }],
182+
sys_user_permission_set: [
183+
{ id: 'ups_a', user_id: 'u1', permission_set: 'admin_full_access', organization_id: 'org_acme' },
184+
],
185+
});
186+
const res = await ensureDefaultOrganization(ql);
187+
expect(res).toMatchObject({ defaultOrgCreated: false, defaultOrgId: 'org_acme', memberCreated: true });
188+
expect(ql.tables.sys_organization).toHaveLength(1);
189+
});
190+
157191
it('picks the OLDEST cross-tenant admin grant', async () => {
158192
const ql = makeQl({
159193
sys_user_permission_set: [

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
* the reconciler's own `member` row on the Default Organization, which is
6767
* promoted to `owner` while the bind is undecided (ADR-0131 D3: that
6868
* organization now exists before the admin does).
69-
* 3. Re-use a pre-existing `slug='default'` org if present; otherwise
69+
* 3. Re-use the Default Organization if present — the `slug='default'`
70+
* org, else the sole org (the boot invariant's definition); otherwise
7071
* create one. Stable slug keeps human-readable URLs predictable across
7172
* cold-boots.
7273
* 4. Insert a `sys_member { role: 'owner' }` linking the admin to the
@@ -86,7 +87,6 @@ import { postureEnforcesWall } from '@objectstack/spec/security';
8687
import { resolveTenancyPosture } from '@objectstack/types';
8788
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';
8889
import { permissionSetInEffect } from './catalog-set-in-effect.js';
89-
import { resolveDefaultOrgId } from './tenancy-service.js';
9090

9191
interface BootstrapLogger {
9292
info: (message: string, meta?: Record<string, any>) => void;
@@ -500,7 +500,7 @@ export async function ensureDefaultOrganization(
500500
// (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`). Reading only
501501
// the organization-less row would answer `no_admin` on every deployment
502502
// promoted since, and never bind its administrator.
503-
const defaultOrgId = await resolveDefaultOrgId(ql);
503+
const defaultOrgId = await findDefaultOrganizationId(ql);
504504
const adminGrants: any[] = [];
505505
for (const organizationId of defaultOrgId ? [null, defaultOrgId] : [null]) {
506506
adminGrants.push(...await tryFind(
@@ -543,11 +543,23 @@ export async function ensureDefaultOrganization(
543543
}
544544

545545
// 4. Re-use or create the `default` org.
546+
//
547+
// [ADR-0131 D3/D5] Under `single`, "re-use" is the Default Organization as
548+
// the boot invariant names it — `slug='default'`, else the sole
549+
// organization — so a deployment whose only organization carries another
550+
// slug binds its admin there rather than minting a second,
551+
// `default`-slugged one. A second one would BECOME the Default Organization
552+
// and move the platform-admin anchor off the grant the promotion wrote
553+
// for the first. Under a wall an organization is a tenant's boundary, never
554+
// "the" organization, so only the `default`-slugged one is re-used.
546555
let defaultOrgId: string | undefined;
547556
let defaultOrgCreated = false;
548-
const existingDefault = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1);
549-
if (existingDefault.length > 0 && existingDefault[0].id) {
550-
defaultOrgId = String(existingDefault[0].id);
557+
const existingDefaultId = legacyGrantAnchorRetired
558+
? (await tryFind(ql, 'sys_organization', { slug: 'default' }, 1))
559+
.map((row) => (row?.id ? String(row.id) : undefined))[0]
560+
: await findDefaultOrganizationId(ql);
561+
if (existingDefaultId) {
562+
defaultOrgId = existingDefaultId;
551563
if (options.bindOnlyOnCreate) {
552564
// The organization predates this call, so whether its owner was bound
553565
// was decided earlier — never re-decided here (ADR-0093 D7).

‎packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,10 @@ async function standingByReader(posture: Posture): Promise<Record<string, unknow
207207
guard.demoteOrganizationOwners = await verdict(() => engine.update(
208208
'sys_member', { role: 'member' }, { where: { role: 'owner' }, multi: true, ...sys },
209209
));
210+
// [ADR-0131 D5] Under `single` the promotion's grant is the Default
211+
// Organization's (here the sole organization, `ORG`) — never organization-less.
210212
const [adminGrant] = (await engine.find('sys_user_permission_set', {
211-
where: { user_id: 'usr_admin', organization_id: null }, context: SYS,
213+
where: { user_id: 'usr_admin', permission_set: 'admin_full_access', organization_id: walled ? null : ORG }, context: SYS,
212214
})) as any[];
213215
guard.revokeSoleAdministratorGrant = adminGrant
214216
? await verdict(() => engine.delete('sys_user_permission_set', { where: { id: adminGrant.id }, ...sys }))
@@ -249,8 +251,15 @@ describe('[ADR-0131 D4] grant readers by name (plugin-auth) — platform standin
249251
* refused; under a wall the declared owner carries no grant row and outlives
250252
* the set's deactivation, so only banning the account itself is refused.
251253
*/
254+
// ⚠️ One recorded move ([ADR-0131] D3/D5): under `single` the fixture's sole
255+
// organization (`ORG`, slug `gra`) IS the Default Organization — the boot
256+
// invariant's definition, which owns the promotion's grant — so the
257+
// default-organization bootstrap binds the platform administrator there beside
258+
// its existing owner, where it used to mint a second, `default`-slugged
259+
// organization that would have taken the anchor off that grant. Every guard
260+
// verdict is unchanged.
252261
const SINGLE: unknown = {
253-
defaultOrganization: { memberCreated: true, reason: null, owners: ['usr_admin'] },
262+
defaultOrganization: { memberCreated: true, reason: null, owners: ['usr_admin', 'usr_orgadmin'] },
254263
lastAdminGuard: {
255264
banMember: 'permitted',
256265
banEveryAdministrator: 'refused:PERMISSION_DENIED',

‎packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ async function soleGrantAnchoredAdmin(): Promise<Rig> {
113113
]),
114114
} as any);
115115
await engine.syncSchemas();
116+
// [ADR-0131 D3/D5] The Default Organization a `single` boot creates before
117+
// `kernel:ready` (AuthPlugin's invariant); it owns the promotion's grant.
118+
await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS);
116119
await plugin.start(ctx);
117120
vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined);
118121
registerGuard(engine);

‎packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ function makeQl(userRows: unknown[]) {
114114
sys_permission_set: [],
115115
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
116116
sys_user_permission_set: [],
117+
// [ADR-0131 D5] The Default Organization, which owns the promotion's grant.
118+
sys_organization: [{ id: 'org_default', slug: 'default' }],
117119
// [#14348] Every probed row that CAN hold an account gets one.
118120
//
119121
// This probe reads plugin-security's human verdict indirectly, as
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D5] Under `single` the first-user promotion's `admin_full_access`
5+
* grant is OWNED BY THE DEFAULT ORGANIZATION, and `resolveAuthzContext` reads
6+
* that row as the anchor of platform standing (`grantAnchorsPlatformStanding`).
7+
* The break-glass guard answers the same question, so two things hold here:
8+
*
9+
* 1. **The enumeration counts that grant's holder.** An enumeration reading
10+
* only the organization-less row would see an environment with no
11+
* administrator and wave through the ban, the revocation or the re-scoping
12+
* that ends the last one.
13+
* 2. **The guard judges the organization writes that move the anchor.** The
14+
* Default Organization is the one with `slug = 'default'`, else the only
15+
* one — so re-slugging it while another organization exists, or deleting
16+
* it, takes the standing away with no grant row touched.
17+
*
18+
* Same method as `last-admin-guard.config-anchor.test.ts`: a REAL ObjectQL
19+
* engine over better-sqlite3 `:memory:`, so the engine dispatches the hooks.
20+
*/
21+
22+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
23+
import { ObjectQL } from '@objectstack/objectql';
24+
import { SqlDriver } from '@objectstack/driver-sql';
25+
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
26+
import { resetPlatformAdminEmailMemo } from '@objectstack/core';
27+
28+
import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js';
29+
import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js';
30+
31+
const POSTURE_ENV = 'OS_TENANCY_POSTURE';
32+
const MULTI_ORG_ENV = 'OS_MULTI_ORG_ENABLED';
33+
const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL';
34+
const SYSTEM = { context: { isSystem: true } } as const;
35+
36+
const text = (name: string) => ({ name, type: 'text' as const });
37+
const OBJECTS = [
38+
{
39+
name: 'sys_user',
40+
label: 'User',
41+
managedBy: 'better-auth',
42+
fields: {
43+
id: { ...text('id'), primaryKey: true },
44+
name: text('name'),
45+
email: text('email'),
46+
email_verified: { name: 'email_verified', type: 'boolean' as const },
47+
banned: { name: 'banned', type: 'boolean' as const, readonly: true },
48+
},
49+
},
50+
{
51+
name: 'sys_member',
52+
label: 'Member',
53+
managedBy: 'better-auth',
54+
fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), organization_id: text('organization_id'), role: text('role') },
55+
},
56+
{
57+
name: 'sys_organization',
58+
label: 'Organization',
59+
managedBy: 'better-auth',
60+
fields: { id: { ...text('id'), primaryKey: true }, name: text('name'), slug: text('slug') },
61+
},
62+
{
63+
name: 'sys_user_permission_set',
64+
label: 'User Permission Set',
65+
fields: {
66+
id: { ...text('id'), primaryKey: true },
67+
user_id: text('user_id'),
68+
permission_set: text('permission_set'),
69+
organization_id: text('organization_id'),
70+
valid_from: { name: 'valid_from', type: 'datetime' as const },
71+
valid_until: { name: 'valid_until', type: 'datetime' as const },
72+
},
73+
},
74+
];
75+
76+
let engines: ObjectQL[] = [];
77+
const ambient: Record<string, string | undefined> = {};
78+
79+
beforeEach(() => {
80+
for (const name of [POSTURE_ENV, MULTI_ORG_ENV, OWNER_ENV]) {
81+
ambient[name] = process.env[name];
82+
delete process.env[name];
83+
}
84+
resetPlatformAdminEmailMemo();
85+
});
86+
87+
afterEach(async () => {
88+
for (const [name, value] of Object.entries(ambient)) {
89+
if (value === undefined) delete process.env[name];
90+
else process.env[name] = value;
91+
}
92+
resetPlatformAdminEmailMemo();
93+
const open = engines;
94+
engines = [];
95+
for (const e of open) {
96+
try { await e.destroy(); } catch { /* noop */ }
97+
}
98+
});
99+
100+
/**
101+
* A `single` environment whose sole administrator holds the grant the promotion
102+
* writes — owned by the Default Organization — and nothing else (no
103+
* membership, so the organization-owner half of the enumeration counts nobody).
104+
*/
105+
async function promotedSingle(opts: { secondOrganization?: boolean; grantOwner?: string } = {}): Promise<ObjectQL> {
106+
const engine = new ObjectQL();
107+
engines.push(engine);
108+
engine.registerDriver(
109+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
110+
true,
111+
);
112+
await engine.init();
113+
for (const o of OBJECTS) engine.registry.registerObject(o as never);
114+
await engine.syncSchemas();
115+
bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] });
116+
registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard.default-org' });
117+
118+
await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM);
119+
if (opts.secondOrganization) {
120+
await engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM);
121+
}
122+
for (const id of ['usr_admin', 'usr_other']) {
123+
await engine.insert('sys_user', { id, name: id, email: `${id}@d.example`, email_verified: true, banned: false }, SYSTEM);
124+
}
125+
await engine.insert('sys_user_permission_set', {
126+
id: 'ups_admin', user_id: 'usr_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: opts.grantOwner ?? 'org_default',
127+
}, SYSTEM);
128+
return engine;
129+
}
130+
131+
const ban = (engine: ObjectQL, id: string) => engine.update('sys_user', { id, banned: true }, SYSTEM);
132+
const refusal = { code: 'PERMISSION_DENIED', status: 403 };
133+
134+
describe('[ADR-0131 D5] the enumeration counts the holder of the Default Organization\'s grant', () => {
135+
it('banning that sole administrator is refused', async () => {
136+
const engine = await promotedSingle();
137+
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal);
138+
});
139+
140+
it('revoking the grant, or re-scoping it to another organization, is refused', async () => {
141+
const engine = await promotedSingle({ secondOrganization: true });
142+
await expect(engine.delete('sys_user_permission_set', { where: { id: 'ups_admin' }, ...SYSTEM } as any))
143+
.rejects.toMatchObject(refusal);
144+
await expect(engine.update('sys_user_permission_set', { id: 'ups_admin', organization_id: 'org_b' }, SYSTEM))
145+
.rejects.toMatchObject(refusal);
146+
});
147+
148+
it('CONTROL — another organization\'s grant of the set makes no administrator: banning its holder is permitted', async () => {
149+
const engine = await promotedSingle({ secondOrganization: true, grantOwner: 'org_b' });
150+
await expect(ban(engine, 'usr_admin')).resolves.toBeDefined();
151+
});
152+
});
153+
154+
describe('[ADR-0131 D5] the organization writes that move the anchor off that grant are judged', () => {
155+
it('re-slugging the Default Organization while another organization exists is refused', async () => {
156+
const engine = await promotedSingle({ secondOrganization: true });
157+
await expect(engine.update('sys_organization', { id: 'org_default', slug: 'renamed' }, SYSTEM))
158+
.rejects.toMatchObject(refusal);
159+
});
160+
161+
it('deleting the Default Organization is refused', async () => {
162+
const engine = await promotedSingle();
163+
await expect(engine.delete('sys_organization', { where: { id: 'org_default' }, ...SYSTEM } as any))
164+
.rejects.toMatchObject(refusal);
165+
});
166+
167+
it('CONTROL — a sole organization stays the Default Organization whatever its slug, and a name change is no standing write', async () => {
168+
const engine = await promotedSingle();
169+
await expect(engine.update('sys_organization', { id: 'org_default', slug: 'acme' }, SYSTEM)).resolves.toBeDefined();
170+
await expect(engine.update('sys_organization', { id: 'org_default', name: 'Acme Inc' }, SYSTEM)).resolves.toBeDefined();
171+
// …and the administrator is still counted afterwards.
172+
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal);
173+
});
174+
});

0 commit comments

Comments
 (0)