Skip to content

Commit abc25a3

Browse files
fix(plugin-auth)!: the last-admin guard and the default-org bootstrap count only the Default Organization's admin_full_access grant (#25141)
Fixes #24883 Clause-②: yes (narrowing) Metadata-protocol: none Seat decision 6324268472 (option A). The measurement behind it is 6324239568. ## Why Since 18.x step 7a-2, `resolveAuthzContext` gives an `admin_full_access` grant platform standing only under `single`, and only when the Default Organization owns it (`grantAnchorsPlatformStanding`). An organization-less grant anchors nothing in any posture (ADR-0131 D1/D5). Two plugin-auth readers still counted such a grant: - the last-admin guard's `anchorOwnerTest`; - the default-organization bootstrap's grant read. Two compositions can still hold that row: - a lean embedding of `registerLastAdminGuard` with no `sys_organization`, over a tenant column that accepts NULL (driver-memory enforces no NOT NULL; a pre-7b-0 SQL table is nullable); - a host outside `os serve`, on a database built before 18.x. In both, the guard believed an administrator remained where the resolver saw none. It **permitted** banning the deployment's only platform administrator, the verified `OS_PLATFORM_OWNER_EMAIL` account. This PR closes that fail-open. ## What changed - **`last-admin-guard.ts`** - `anchorOwnerTest` reads the resolver's rule: a grant anchors only when the Default Organization owns it. Under a wall, or with no nameable Default Organization, none anchors. - The header (grade 1), the docblocks and the refusal texts no longer say "unscoped" or "or by none". - The walled-remedy clause of the emptied-not-fresh refusal is retired. Under a wall no grant is evidence any more, so the clause could not be reached. - **`ensure-default-organization.ts`:** the bootstrap reads only the Default Organization's grant, and no grant when none is nameable. The organization-less read is gone. - **Tests** (the 81 cases the measurement predicted turned red at the first source commit, and all are re-fixtured; none is skipped or weakened): - Guard suites (47): - each engine registers `sys_organization` with a `default` row, and the platform-admin grant is owned by it; - the suites now run on a fresh NOT NULL store (`relaxEveryRegisteredTenantColumn` dropped), except where a case deliberately models a pre-18 row; - `sqliteEnginePool` gains `keepBootOrganizations`, which keeps the boot's organization rows and refuses a case that changed them. - The config-anchor L5 suite now pins BOTH keys, posture and owner, in both directions: - an organization-less holder is no survivor under `single` either: the new direction; - the Default Organization's grant still anchors under `single`. - Bootstrap suites (34): the fake engines hold the Default Organization the boot invariant creates, and the promotion's grant is owned by it. The org-create cases go through the config anchor, the one population that needs no organization first. - New `last-admin-guard.anchor-parity.test.ts` (13 cases) runs a real engine with the real resolver: - the write-feasibility matrix on sqlite, with the tenant column nullable where the row models a lean embedding or a pre-18 store; with and without the organization object; 0, 1 and 2 organizations; `single` and `isolated`. Each case asserts what the engine does with an organization-less grant, then that the guard counts its holder exactly when the resolver does; - **the negative pin, failure direction (ii)**, on a lean embedding with no organization object over a nullable tenant column, and on a pre-18 store: banning the resolver's only platform administrator is refused; - the positive control. - Two new bootstrap pins: an organization-less grant binds nobody, and with no nameable Default Organization no grant is read. - **Deviation from the dispatch:** the lean-embedding pin runs on sqlite with a nullable tenant column, not on driver-memory. `check:driver-memory-census` refuses a new driver-memory consumer without a maintainer ruling, and the guard takes the same path on both drivers. No dependency is added. - `content/docs/permissions/authorization.mdx`: the platform-admin anchor wording, and the embedder's remedy. - One `patch` changeset with the BREAKING banner, the FROM → TO, and the embedder's remedy (D5's config anchor plus membership). ## Verification (local, before CI) - `@objectstack/plugin-auth`: 3448 tests, 3438 passed, 0 failed, 10 skipped. `pnpm typecheck` exit 0, after a full DTS build of the plugin-auth closure. - `@objectstack/organizations`: 233/233. - Ablation, each restored blob == HEAD: - putting the organization-less half back in `anchorOwnerTest` turns 6 cases red: the matrix's no-organization-object row, both negative pins, and three config-anchor pins; - putting the bootstrap's null element back turns the 2 new bootstrap pins red. - `check-governed-merges --branch`: NOT governed, 0 of 15 paths hit the register (14 at `980cdc13c`, plus `permission-sets.mdx` in `bc359a7c`). - The full gate list, the dogfood battery (organization-delete included) and R1/R4 are reported on the card in the os-dev report, against the head they ran on. ## Acceptance notes - The guard's `judgeOrganizationDelete` (#25016) asks the same count, so it inherits the fix. Its pins are re-run with the dogfood battery. - File surface: the claim names the two source files, their tests, the docs page and one changeset. The PR stays inside it: no `package.json` or lockfile change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p --- _Generated by [Claude Code](https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60b42cd commit abc25a3

15 files changed

Lines changed: 897 additions & 322 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The rows this reads differently exist only on a database the ownership ceremony has not certified, or in a composition that registers no organization object. -->
11+
12+
**BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way.
13+
14+
- **`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before.
15+
- The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded.
16+
- It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator.
17+
- Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there).
18+
- **`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before.
19+
20+
**Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified.
21+
22+
**What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,10 @@ implementation detail:
133133
deployment's **configured administrator list** — an address declared in
134134
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
135135
**email-verified** `sys_user` row — is sufficient under every posture. An
136-
**unscoped `admin_full_access` grant** row is sufficient only under
137-
`single`; under the walled postures (`group` / `isolated`) it is no longer
138-
an anchor — the set it names still grants what any held set grants, but it
136+
**`admin_full_access` grant owned by the Default Organization** is
137+
sufficient only under `single`; a grant owned by no organization anchors
138+
nothing in any posture, and under the walled postures (`group` /
139+
`isolated`) no grant is an anchor — the set it names still grants what any held set grants, but it
139140
confers no `PLATFORM_ADMIN` standing, so there standing comes from
140141
configuration alone. See
141142
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
@@ -361,7 +362,7 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
361362
the explain engine, sharing-rule position expansion, and (transitively) the
362363
delegated-admin gate's held-scope resolution. No background cleanup job is
363364
involved (ADR-0049); the clock is checked on every resolution. An expired
364-
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
365+
`admin_full_access` grant no longer derives `platform_admin` — a
365366
statement about the *grant* anchor. The configured-administrator anchor is not
366367
a stored grant row at all, so it carries no validity window and nothing here
367368
dates it; it is revoked by changing the configuration.
@@ -450,7 +451,7 @@ high-privilege gate runs on the definition each time it is derived. See
450451

451452
- A deactivated **permission set** contributes nothing: not its name, not its
452453
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
453-
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
454+
deactivated `admin_full_access` no longer derives `platform_admin`
454455
— the flag is applied *before* the posture is derived, not after. A
455456
**configured** administrator is judged elsewhere: that anchor reads the
456457
deployment's configuration and the caller's own `sys_user` row, never the
@@ -480,6 +481,14 @@ grant away; a permission set or position with no ledger row is in effect. The
480481
last-administrator guard reads the same two places the resolver does: whether
481482
the catalogue holds `admin_full_access`, and whether the ledger switches it
482483
off. It reads no catalogue row, so a row's `active` changes nothing it counts.
484+
It counts the same anchors the resolver derives `platform_admin` from — under
485+
`single`, the Default Organization's `admin_full_access` grant; under every
486+
posture, the configured administrator list — beside organization owners and
487+
admins. A grant owned by no organization counts nowhere. So a host that
488+
registers the guard (`registerLastAdminGuard` from `@objectstack/plugin-auth`)
489+
on an engine with no organization object has no grant-anchored administrator
490+
at all: its administrators are the accounts declared in
491+
`OS_PLATFORM_OWNER_EMAIL` and the organization owners and admins it holds.
483492

484493
**Switching the break-glass set off is refused while it would leave no
485494
administrator.** A ledger row switching `admin_full_access` off un-makes every

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,17 @@ sufficient depends on the deployment's
187187
are one administrator, not two half-matches. Nothing is stored and nothing is
188188
granted — the answer is recomputed from configuration on every resolution.
189189
This anchor is read under every posture.
190-
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
191-
row with `organization_id = NULL`, the original anchor. It confers standing
192-
under the `single` posture only. Under the walled postures (`group` /
193-
`isolated`) the row is no longer an anchor: the set it names still grants
194-
what any held set grants, but it confers no `PLATFORM_ADMIN` standing, and
195-
nothing is logged about it per request. If a walled deployment holding such a
196-
row ends up with no usable configured administrator, the boot log says it has
197-
zero platform administrators, names the account that holds the row, and points
198-
at `OS_PLATFORM_OWNER_EMAIL` as the way to restore that account's standing.
190+
- **The Default Organization's `admin_full_access` grant row** — a
191+
`sys_user_permission_set` row owned by the Default Organization, the row the
192+
`single` first-user promotion writes. It confers standing under the `single`
193+
posture only. An organization-less grant (`organization_id = NULL`, the row an
194+
earlier release's promotion wrote, which the ownership ceremony attributes to
195+
an organization) anchors nothing, in any posture. Under the walled postures
196+
(`group` / `isolated`) no grant row is an anchor: the set it names still
197+
grants what any held set grants, but it confers no `PLATFORM_ADMIN` standing,
198+
and nothing is logged about it per request. A walled deployment that declares
199+
no usable configured administrator is told so at boot: the log says it has
200+
zero platform administrators and points at `OS_PLATFORM_OWNER_EMAIL`.
199201

200202
Which anchor a fresh deployment gets depends on its
201203
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first

‎packages/plugins/plugin-auth/src/__tests__/sqlite-engine-pool.testkit.ts‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
* - the engine's memo of the install's organizations, which only an engine
2222
* write to `sys_organization` invalidates: the rows are deleted below the
2323
* engine, so a database that holds an organization row is refused rather
24-
* than emptied;
24+
* than emptied — unless the pool KEEPS the rows its boot wrote
25+
* (`keepBootOrganizations`): they are then left in place, and a case that
26+
* changed them is refused, so the memo still describes the table;
2527
* - the schema itself: a case that created or dropped a table leaves an
2628
* engine a fresh boot would not give, and the next `take()` refuses it;
2729
* - the write epoch and the log-once sets: counters and logging only, read by
@@ -54,6 +56,12 @@ export interface BootedSqliteEngine<E extends ObjectQL = ObjectQL> {
5456
export interface SqliteEnginePoolOptions<E extends ObjectQL> {
5557
/** Per-engine fixture state the suite keeps beside the database (a catalog set, say), reset with it. */
5658
onReset?: (engine: E) => void | Promise<void>;
59+
/**
60+
* Keep the `sys_organization` rows the boot wrote (a Default Organization, say)
61+
* across resets instead of refusing them. A case that adds, changes or removes
62+
* one is refused at the next `take()`, as a schema change is.
63+
*/
64+
keepBootOrganizations?: boolean;
5765
}
5866

5967
export interface SqliteEnginePool<E extends ObjectQL> {
@@ -72,6 +80,8 @@ interface Entry<E extends ObjectQL> {
7280
driver: SqlDriver;
7381
/** The tables the boot created, sorted. */
7482
tables: readonly string[];
83+
/** The `sys_organization` rows the boot wrote, serialized, when the pool keeps them. */
84+
bootOrganizations?: string;
7585
held: boolean;
7686
}
7787

@@ -80,6 +90,11 @@ const ORGANIZATION_TABLE = 'sys_organization';
8090

8191
type Knex = ReturnType<SqlDriver['getKnex']>;
8292

93+
async function organizationRows(knex: Knex): Promise<string> {
94+
const rows = (await knex(ORGANIZATION_TABLE).select('*')) as Array<Record<string, unknown>>;
95+
return JSON.stringify(rows.map((row) => JSON.stringify(row)).sort());
96+
}
97+
8398
async function tableNames(knex: Knex): Promise<string[]> {
8499
const rows = (await knex('sqlite_master').where({ type: 'table' }).select('name')) as Array<{ name: string }>;
85100
return rows
@@ -121,7 +136,11 @@ export function sqliteEnginePool<E extends ObjectQL>(
121136
'memory across a reset; boot that engine per case instead of pooling it.',
122137
);
123138
}
124-
const entry: Entry<E> = { engine, driver, tables: await tableNames(driver.getKnex()), held: true };
139+
const tables = await tableNames(driver.getKnex());
140+
const entry: Entry<E> = { engine, driver, tables, held: true };
141+
if (options.keepBootOrganizations && tables.includes(ORGANIZATION_TABLE)) {
142+
entry.bootOrganizations = await organizationRows(driver.getKnex());
143+
}
125144
entries.push(entry);
126145
return entry;
127146
}
@@ -138,7 +157,15 @@ export function sqliteEnginePool<E extends ObjectQL>(
138157
'Boot that case its own engine.',
139158
);
140159
}
141-
if (tables.includes(ORGANIZATION_TABLE)) {
160+
const keptOrganizations = entry.bootOrganizations !== undefined;
161+
if (keptOrganizations && (await organizationRows(knex)) !== entry.bootOrganizations) {
162+
throw new Error(
163+
`sqliteEnginePool: a case changed the ${ORGANIZATION_TABLE} rows this pool keeps from the boot, and the ` +
164+
'engine memoizes the organization set until an engine write to that object invalidates it. Boot that ' +
165+
'case its own engine.',
166+
);
167+
}
168+
if (tables.includes(ORGANIZATION_TABLE) && !keptOrganizations) {
142169
const [row] = (await knex(ORGANIZATION_TABLE).count({ n: '*' })) as Array<{ n: number | string }>;
143170
if (Number(row?.n ?? 0) > 0) {
144171
throw new Error(
@@ -151,7 +178,10 @@ export function sqliteEnginePool<E extends ObjectQL>(
151178
await knex.transaction(async (trx) => {
152179
// Checked at commit, when every table is empty: the delete order does not matter.
153180
await trx.raw('PRAGMA defer_foreign_keys = ON');
154-
for (const table of tables) await trx(table).del();
181+
for (const table of tables) {
182+
if (keptOrganizations && table === ORGANIZATION_TABLE) continue;
183+
await trx(table).del();
184+
}
155185
const [sequence] = (await trx('sqlite_master').where({ type: 'table', name: 'sqlite_sequence' })) as unknown[];
156186
if (sequence) await trx('sqlite_sequence').del();
157187
});

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

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,30 @@ import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objec
88
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
99
import { SettingsService, brandingSettingsManifest } from '@objectstack/service-settings';
1010

11+
/**
12+
* [ADR-0131 D3/D5] The `single` first-user promotion's grant, owned by the
13+
* Default Organization the boot invariant creates before any user: its owner is
14+
* read off `tables` as the Default Organization stands (`slug = 'default'`, else
15+
* the only one) the first time one exists, and kept from then on — so the
16+
* fixture holds the owner `start()` gives it rather than an organization-less
17+
* row no reader anchors on, and a Default Organization deleted later leaves the
18+
* grant naming the organization that is gone, as a stored row would.
19+
*/
20+
function promotionGrant(tables: Record<string, any[]>, userId: string): Record<string, unknown> {
21+
let owner: string | undefined;
22+
return {
23+
id: 'ups1',
24+
user_id: userId,
25+
permission_set_id: 'ps_admin',
26+
permission_set: 'admin_full_access',
27+
get organization_id() {
28+
const orgs = tables.sys_organization ?? [];
29+
owner ??= (orgs.find((o) => o.slug === 'default') ?? (orgs.length === 1 ? orgs[0] : undefined))?.id;
30+
return owner;
31+
},
32+
};
33+
}
34+
1135
describe('AuthPlugin', () => {
1236
let mockContext: PluginContext;
1337
let authPlugin: AuthPlugin;
@@ -1438,12 +1462,11 @@ describe('AuthPlugin', () => {
14381462
const makeQl = () => {
14391463
const tables: Record<string, any[]> = {
14401464
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
1441-
sys_user_permission_set: [
1442-
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
1443-
],
1465+
sys_user_permission_set: [],
14441466
sys_member: [],
14451467
sys_organization: [],
14461468
};
1469+
tables.sys_user_permission_set.push(promotionGrant(tables, 'u1'));
14471470
const matches = (row: any, where: any) =>
14481471
Object.entries(where ?? {}).every(([k, v]) => {
14491472
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
@@ -1693,9 +1716,7 @@ describe('AuthPlugin', () => {
16931716
const makeQl = () => {
16941717
const tables: Record<string, any[]> = {
16951718
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
1696-
sys_user_permission_set: [
1697-
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
1698-
],
1719+
sys_user_permission_set: [],
16991720
// The platform admin already exists; the default-org bootstrap binds it
17001721
// as `owner` on kernel:ready. A member-less seeded user is added later.
17011722
sys_user: [{ id: 'admin' }],
@@ -1704,6 +1725,7 @@ describe('AuthPlugin', () => {
17041725
// The deployment ledger the one-time pass records itself in.
17051726
sys_migration: [],
17061727
};
1728+
tables.sys_user_permission_set.push(promotionGrant(tables, 'admin'));
17071729
const matches = (row: any, where: any) =>
17081730
Object.entries(where ?? {}).every(([k, v]) => {
17091731
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
@@ -1836,17 +1858,30 @@ describe('AuthPlugin', () => {
18361858
expect(ql.tables.sys_organization).toHaveLength(0);
18371859
expect(ql.tables.sys_migration).toHaveLength(0);
18381860

1839-
// The admin's grant lands: the bootstrap creates the organization, and
1840-
// that moment runs the pass — no restart, no app:seeded.
1841-
ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null });
1842-
await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' });
1843-
await vi.waitFor(() => {
1844-
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' });
1845-
});
1846-
expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([
1847-
'adr-0093-default-org-owner-bind',
1848-
'adr-0093-membership-backfill',
1849-
]);
1861+
// The declared owner's verified account lands: the bootstrap creates the
1862+
// organization, and that moment runs the pass — no restart, no
1863+
// app:seeded. [ADR-0131 D5] The config anchor is the population that
1864+
// needs no organization first; a grant anchors only once the Default
1865+
// Organization owns it.
1866+
const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL;
1867+
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
1868+
try {
1869+
Object.assign(ql.tables.sys_user.find((u: any) => u.id === 'admin'), {
1870+
email: 'operator@corp.example',
1871+
email_verified: true,
1872+
});
1873+
await fireBootstrapWrite({ object: 'sys_user', operation: 'update', data: { email_verified: true } });
1874+
await vi.waitFor(() => {
1875+
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' });
1876+
});
1877+
expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([
1878+
'adr-0093-default-org-owner-bind',
1879+
'adr-0093-membership-backfill',
1880+
]);
1881+
} finally {
1882+
if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
1883+
else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner;
1884+
}
18501885
});
18511886

18521887
it('unreadable ledger + an existing default organization + an unbound admin: the admin is NOT bound', async () => {
@@ -1927,9 +1962,23 @@ describe('AuthPlugin', () => {
19271962
expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind');
19281963
ql.tables.sys_organization.length = 0;
19291964
ql.tables.sys_member.length = 0;
1930-
await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' });
1931-
expect(ql.tables.sys_organization).toHaveLength(1);
1932-
expect(ql.tables.sys_member).toHaveLength(0);
1965+
// [ADR-0131 D5] The grant still names the organization that is gone, so
1966+
// it anchors nobody now; the declared, verified owner is who the
1967+
// bootstrap recreates the organization for.
1968+
const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL;
1969+
process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example';
1970+
try {
1971+
Object.assign(ql.tables.sys_user.find((u: any) => u.id === 'admin'), {
1972+
email: 'operator@corp.example',
1973+
email_verified: true,
1974+
});
1975+
await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' });
1976+
expect(ql.tables.sys_organization).toHaveLength(1);
1977+
expect(ql.tables.sys_member).toHaveLength(0);
1978+
} finally {
1979+
if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL;
1980+
else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner;
1981+
}
19331982
});
19341983

19351984
it('once the pass is recorded, a later trigger binds nobody', async () => {

0 commit comments

Comments
 (0)