Skip to content

Commit 2e10c9a

Browse files
feat(core,plugin-security,plugin-auth)!: the authorization resolver reads a grant's permission set by name (ADR-0131 D4, C2 stage S5a) (#22495)
Part of #15196 Clause-②: no (narrowing) C2 stage **S5a** (`domain:engine`): the authorization resolver reads which permission set a user grant holds from the grant's **name**, `sys_user_permission_set.permission_set` (ADR-0131 D4). The card stays open for S5c, S8, S9 and S10. ## What changes **The resolver** (`packages/core/src/security/resolve-authz-context.ts`, `resolveUserAuthzGrants` §6 and §6b): - A user grant's set row is found by its name: the grant's own organization's row, otherwise the organization-less row (`readGrantSetRowsByName`). The grant's `permission_set_id` is no longer read here. - A grant that names nothing, or whose name resolves only to another organization's set, confers nothing. - **Platform-admin anchor.** An unscoped grant reads the organization-less `admin_full_access` row only. Deactivation is still read from the row. Under walled postures the anchor stays retired. - **Position-bound sets** are still reached through the junction's id. That relation belongs to C3 (`KEPT-C3`, untouched). - The by-name read is issued beside the `sys_position` read, so it adds no sequential leg. - `ADMIN_STANDING_SURFACE` declares the columns the resolver now reads: `sys_user_permission_set.permission_set` in place of the id, and `sys_permission_set.organization_id`. **Prerequisite 2: the S4a name hook applies the S4b rule at write time** (`grant-permission-set-name.ts`). - Measured first, at the write door: a tenant-less system writer inserting an organization-less grant whose id named another organization's set stored the name `qa_b_only`, that set's name. - Now a name is taken only from a set row of the grant's own organization, or from an organization-less row. - Another organization's set is never named after: - a supplied name is refused for every caller, system included, with the hook's existing `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`; - no name is stamped; - an update that re-points the grant at such a set, or moves the grant to an organization its set does not belong to, clears the name. **Prerequisite 1: the upgrade-boot window, accounted for in S5a.** - The backfill runs at `kernel:bootstrapped`. Both kernels await every `kernel:bootstrapped` handler before `kernel:listening`, where HTTP servers open their socket. So no request reaches the resolver while the grants the backfill can name are still unnamed. - Only boot code running at `kernel:ready` sees them unnamed. Pinned on a real `LiteKernel` boot. - Route C (moving the backfill ahead of the `kernel:ready` readers) was not taken. S4b measured that a `kernel:ready` placement misses late-registered sets (its ablation A6). Under a by-name resolver those grants would then confer nothing for the whole process lifetime. - `security-plugin.ts` is not touched. **The S4b carrier: a grant whose id names another organization's set confers nothing.** This follows the seat's ruling: a set resolves by name, in its own organization's row and otherwise the organization-less row. **Break-glass guard** (`plugin-auth`, `last-admin-guard.ts`). The resolver now reads `sys_permission_set.organization_id`, so the guard's correspondence gate asks for a disposition. The guard now: - treats the column as a standing column; - counts only the organization-less `admin_full_access` row as the anchor, matching the resolver; - refuses moving that row into an organization when no administrator would remain. ## Read sites (census rows 31–35, located by symbol on `35ef501e1`) | Site | Leg | Moved or kept | Why | |:--|:--|:--|:--| | `sys_user_position` read (Leg 1) | assignment by position name | kept | already a name (`ASSIGN`) | | `sys_user_permission_set` read (Leg 1) | which set a grant holds | **moved**: `permission_set_id` → `permission_set` | `REWRITE-C2` | | same | grant organization, validity window | kept | unchanged grant rule | | `sys_position` read (§6a) | position existence and `active` | kept | `KEPT-C3` id bridge, `OPEN-ACTIVE`; `REG` is S8a | | `sys_position_permission_set` read (§6a) | binding | kept | `KEPT-C3` | | `sys_permission_set` read (§6b) | user-grant leg | **moved**: by id, installation-wide → by name, own organization's row else the organization-less one | `REWRITE-C2` and the S4b carrier | | same | position-bound leg | kept, by the junction's id | `KEPT-C3` (id→name bridge) | | same | `active`, capability body | kept on the row | Q2 = A (`OPEN-ACTIVE`); `REG(body)` is S8a | | platform-admin anchor (§6b) | unscoped `admin_full_access` grant | **moved**: by id → by name, on the organization-less row | it read a grant by id (`unscopedUserPsIds`) | | `platform-admin.ts` (§6b-config) | declared owner email | untouched | reads no grant | `@objectstack/core` cannot import `plugin-security`. It keeps its own internal copy of the rule (`grantSetNameOf`, not exported), the shape S5b set ("one copy per package, internal"). No new dependency edge, no cycle. ## Pins, each reverse-verified (one-time runs, quoted in the report) - **(a) Goldens per principal, base-recorded.** `resolve-authz-grant-set-by-name.golden.test.ts` covers the platform admin, the organization admin, a member and an agent, in `single`, `group` and `isolated`. Each records the envelope inside the organization and outside it, plus `hasPlatformAdminStanding`. - Recorded on `61765bfb`: goldens only, production code at the base. - Unchanged with the change. - S5b's and S4a's goldens are unchanged too. - **(b) The census ablation.** One grant's name was pointed at another set, with the name hooks unbound, via `ablation-replace`. - With the change, each leg turns its golden red in every posture that holds that grant: member 3/3, agent 3/3, organization admin `single` 1/3 and walled 2/3, platform admin `single` 1/3 (no grant row under a wall). - On the base resolver, all five re-points at once leave the goldens green, 3/3. - **(c) A grant whose id names another organization's set confers nothing.** Pinned in core (recording double) and in plugin-security (SQL driver, real hooks). Red on the base resolver. - **(d) An unnamed grant confers nothing, and its id still restricts.** Pinned in core and in plugin-security: the organization-admin reconcile still revokes an unnamed grant through its id. Red on the base resolver. - **(e) The upgrade boot.** On a real `LiteKernel` boot, a grant stored before the name column is unnamed at `kernel:ready` and confers at `kernel:listening`. - The `kernel:ready` leg is red on the base resolver. - Unwiring the backfill turns the `kernel:listening` leg red. - **(f) Write-time rule in the hook.** Four pins: stored unnamed, supplied name refused, a re-point clears the name, an organization move clears the name. All four are red with the hook at the base; the control stays green. - **Guard.** Moving the organization-less `admin_full_access` into an organization is refused. Red with the guard at the base; the two controls stay green. ## Fixture triage The resolver no longer reads the id, so test grants written without the name hooks now carry the name every platform writer stores beside the id. Hand-written engine doubles now read an absent column as NULL, as SQL does, and answer a `sys_permission_set` read by name. - The core batch-equivalence **query** goldens move for the `sys_permission_set` entries of the three fixtures that hold a user grant. The move is written down in the test file, as its header requires. - Every other recorded read, and every **envelope**, is unchanged. - The leg count drops from 3 to 2 for two fixtures. - S4b's "no principal's grants change" pin now states the new truth: before the backfill, unnamed grants confer nothing; after it, the golden holds. ## Cross-lane paths (`domain:services`, beyond the claim's file surface) - `packages/plugins/plugin-security/src/grant-permission-set-name.ts`, as claimed. - `packages/plugins/plugin-auth/src/last-admin-guard.ts`, not in the claim. This is the guard's half of the surface this stage changed: its correspondence gate is red without it. - Tests in plugin-security: `grant-permission-set-name.test.ts`, `grant-permission-set-name-backfill.test.ts`, `resolve-authz-grant-set-by-name.test.ts` and `resolve-authz-grant-set-by-name.golden.test.ts` (both new), and the fixtures of `explain-enforce-parity`, `explain-engine`, `explain-positions-name-authority`, `explain-removed-member-principal`, `orgless-position-name-fold` and `security-plugin`. - Tests in plugin-auth: `last-admin-guard.test.ts` and the fixtures of 10 test files. - Test fixtures only: rest (11 files), runtime (11), plugin-hono-server (5), cloud-connection (5), plugin-sharing (1), plugin-approvals (2), service-automation (1), service-datasource (2), service-settings (1). - No `security-plugin.ts`, no `packages/spec`, no id-column change, no deletion, no driver arm, no junction read. ## Verification at `2e2d3bec` `origin/main` `35ef501e1` was merged in clean. - **Suites:** - core: 2278 passed, typecheck 0; - plugin-security: 3958 passed, 45 skipped, typecheck 0; - plugin-auth: 2731 passed, 10 skipped, typecheck 0. - **Grant-fixture test files in the consumer packages:** rest 240, runtime 247, plugin-hono-server 43, client 50, cloud-connection 514 (full), service-datasource 763 (full), plugin-sharing 35, plugin-approvals 24, service-automation 20, service-settings 18, mcp 21, organizations 38, objectql 173, lint 147, triggers 23, verify 2. All passed. - **Dogfood:** 40 files that read or write grant rows; 427 passed, 1 skipped. - **Gates:** `dispatch-gates --commands` derived 71; all 71 exit 0. `--ran` answers "71 derived, 71 run, 0 NOT-MEASURED (a DERIVED zero)". Two needed a prerequisite before they ran: a fixture commit fetched for `check-plugin-teardown-shape --self-test`, and seven unrelated packages built for `check:dual-build-cjs-loads`. - **`check:durability-log-level`:** exit 0. - **Lint (narrowed):** `eslint --no-inline-config --format json` over the 73 changed TS files: 73 linted, 0 errors, 0 warnings, none ignored. `eslint.config.mjs` enables no type-aware linting, so untouched files are invariant. - **Integration layer:** the `cli` integration test (a write through the real data door, stamped by the hook) and `http-conformance` are left to CI. ## Acceptance notes - **Unnamed grants on a real upgrade.** The backfill leaves some grants unnamed for good: an id with no set row, an id on another organization's set, or a name the catalog does not resolve. After this stage those grants confer nothing. That is the ruled fail-closed answer, and they are listed in the backfill's boot report. - A deployment holding row-only permission sets from before the set write-through would see their grants stay unnamed (catalog-unresolved) and stop conferring. This is inference from source; no such deployment was measured. - **The backfill's failure line** in `security-plugin.ts` still says unnamed grants "keep no name until a later boot runs it". With this stage they also confer nothing until then. - Carrier: the next stage that holds `security-plugin.ts`. Not edited here. - **Three copies of the by-name rule** now exist: core, plugin-security and plugin-auth, each internal. Core is upstream of both plugins, so one exported copy in core is possible. Recorded as an open question, not built. --- _Generated by [Claude Code](https://claude.ai/code/session_01W6VxfDKkkpAFgax4onpHt8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e148ca9 commit 2e10c9a

75 files changed

Lines changed: 1884 additions & 261 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/plugin-auth': minor
5+
---
6+
7+
feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export, type or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is runtime resolution and two write doors: a stored grant that names no permission set, or names one only another organization holds, stops conferring through the resolver; a write naming a grant after another organization's set is refused; and the break-glass guard judges one more column. The remedy is data (a grant re-pointed at a set of its own organization), never a rewrite of anyone's code or metadata, and the one-time backfill that names existing grants already ships. The other categories are closed on facts: every package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no TypeScript declaration moves (not runtime-interface-only or type-surface-only). -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
**What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id.
16+
17+
- **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing.
18+
- **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`.
19+
20+
**What stops conferring.**
21+
22+
- **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report.
23+
- **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing.
24+
- **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again.
25+
26+
**What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name.
27+
28+
**What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect.
29+
30+
**Nothing to migrate** in code or metadata.

‎packages/cloud-connection/src/install-local-principal.fixtures.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record<s
5353
sys_position: [],
5454
sys_position_permission_set: [],
5555
sys_user_permission_set: [
56-
{ id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', organization_id: null },
56+
{ id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', permission_set: 'admin_full_access', organization_id: null },
5757
],
5858
sys_permission_set: [
5959
{

‎packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ function grantRows(shape: Shape): Record<string, any[]> {
134134
sys_position: [],
135135
sys_position_permission_set: [],
136136
sys_user_permission_set: held.length
137-
? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }]
137+
? [{
138+
id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1',
139+
permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', organization_id: null,
140+
}]
138141
: [],
139142
sys_permission_set: held.length
140143
? [{ id: 'ps1', name: shape === 'capable' ? 'admin_full_access' : 'organization_admin', system_permissions: held }]

‎packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ function grantRows(shape: Shape): Record<string, any[]> {
111111
sys_position: [],
112112
sys_position_permission_set: [],
113113
sys_user_permission_set: held.length
114-
? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }]
114+
? [{
115+
id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1',
116+
permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', organization_id: null,
117+
}]
115118
: [],
116119
sys_permission_set: held.length
117120
? [{

‎packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ const MEMBER_GRANTS: Record<string, unknown[]> = {
8989
sys_user_position: [],
9090
sys_position: [],
9191
sys_position_permission_set: [],
92-
sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', organization_id: null }],
92+
sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: null }],
9393
sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }],
9494
};
9595

@@ -125,6 +125,8 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di
125125
if (k.startsWith('$') || (v !== null && typeof v === 'object')) {
126126
throw new Error(`only scalar equality is implemented here (got '${k}')`);
127127
}
128+
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
129+
if (v === null) return (row[k] ?? null) === null;
128130
return row[k] === v;
129131
}));
130132
return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row }));

‎packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ function matchesWhere(row: any, where: any): boolean {
108108
if (!(cond as any).$in.includes(row[field])) return false;
109109
continue;
110110
}
111+
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
112+
if (cond === null) {
113+
if ((row[field] ?? null) !== null) return false;
114+
continue;
115+
}
111116
if (row[field] !== cond) return false;
112117
}
113118
return true;
@@ -143,10 +148,10 @@ function permissionStore(opts: { syncSchemas: () => Promise<void> }) {
143148
sys_position: [],
144149
sys_position_permission_set: [],
145150
sys_user_permission_set: [
146-
{ id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', organization_id: null },
147-
{ id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', organization_id: null },
148-
{ id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', organization_id: null },
149-
{ id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', organization_id: null },
151+
{ id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
152+
{ id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
153+
{ id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
154+
{ id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
150155
],
151156
sys_permission_set: [
152157
{ id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] },

‎packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export function makeRecordingQl(tables: Record<string, unknown[]>) {
5858
Object.entries(where ?? {}).every(([k, v]) => {
5959
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
6060
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
61+
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
62+
if (v === null) return (row[k] ?? null) === null;
6163
return row[k] === v;
6264
});
6365
return {
@@ -253,11 +255,11 @@ export const FIXTURES: Fixture[] = [
253255
sys_member: [{ user_id: 'u_ps', organization_id: 'org_a', role: 'member' }],
254256
sys_user_position: [],
255257
sys_user_permission_set: [
256-
{ user_id: 'u_ps', permission_set_id: 'ps_admin', organization_id: null },
257-
{ user_id: 'u_ps', permission_set_id: 'ps_org', organization_id: 'org_a' },
258-
{ user_id: 'u_ps', permission_set_id: 'ps_other', organization_id: 'org_z' },
259-
{ user_id: 'u_ps', permission_set_id: 'ps_lapsed', organization_id: null, valid_until: past },
260-
{ user_id: 'u_ps', permission_set_id: 'ps_dead', organization_id: null },
258+
{ user_id: 'u_ps', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
259+
{ user_id: 'u_ps', permission_set_id: 'ps_org', permission_set: 'org_tools', organization_id: 'org_a' },
260+
{ user_id: 'u_ps', permission_set_id: 'ps_other', permission_set: 'other_org_tools', organization_id: 'org_z' },
261+
{ user_id: 'u_ps', permission_set_id: 'ps_lapsed', permission_set: 'lapsed_set', organization_id: null, valid_until: past },
262+
{ user_id: 'u_ps', permission_set_id: 'ps_dead', permission_set: 'dead_set', organization_id: null },
261263
],
262264
sys_permission_set: [
263265
{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] },
@@ -279,7 +281,7 @@ export const FIXTURES: Fixture[] = [
279281
sys_user: [{ id: 'u_ta' }],
280282
sys_member: [{ user_id: 'u_ta', organization_id: 'org_a', role: 'admin' }],
281283
sys_user_position: [],
282-
sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', organization_id: null }],
284+
sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', permission_set: 'low', organization_id: null }],
283285
sys_position: [{ id: 'p_orgadmin', name: 'org_admin' }, { id: 'p_everyone', name: 'everyone' }],
284286
sys_position_permission_set: [{ position_id: 'p_orgadmin', permission_set_id: 'ps_oa' }],
285287
sys_permission_set: [
@@ -330,7 +332,7 @@ export const FIXTURES: Fixture[] = [
330332
sys_user: [{ id: 'u_seed', email: 'ignored@x.com', ai_access: 1 }],
331333
sys_member: [{ user_id: 'u_seed', organization_id: 'org_a', role: 'member' }],
332334
sys_user_position: [],
333-
sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', organization_id: null }],
335+
sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', permission_set: 'extra', organization_id: null }],
334336
sys_permission_set: [{ id: 'ps_x', name: 'extra' }],
335337
sys_position: [],
336338
},

‎packages/core/src/security/admin-standing-surface.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ function makeRecordingQl(tables: Record<string, Array<Record<string, unknown>>>,
9494
if ('$nin' in c) return !(c.$nin as unknown[]).includes(raw(row, key));
9595
if ('$ne' in c) return raw(row, key) !== c.$ne;
9696
}
97+
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
98+
if (cond === null) return (raw(row, key) ?? null) === null;
9799
return raw(row, key) === cond;
98100
}),
99101
);
@@ -165,7 +167,7 @@ const VARIANTS: Record<
165167
{
166168
id: 'ups_1',
167169
user_id: 'usr_1',
168-
permission_set_id: 'pst_1',
170+
permission_set_id: 'pst_1', permission_set: 'admin_full_access',
169171
organization_id: null,
170172
valid_from: null,
171173
valid_until: null,
@@ -241,7 +243,7 @@ const VARIANTS: Record<
241243
{
242244
id: 'ups_1',
243245
user_id: 'usr_1',
244-
permission_set_id: 'pst_1',
246+
permission_set_id: 'pst_1', permission_set: 'admin_full_access',
245247
organization_id: 'org_1',
246248
valid_from: new Date(NOW - HOUR).toISOString(),
247249
valid_until: new Date(NOW + HOUR).toISOString(),

‎packages/core/src/security/admin-standing-surface.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,17 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
116116
+ "`ADMIN_FULL_ACCESS_CAPABILITIES` in `@objectstack/spec` and matches the caller's own "
117117
+ 'stored `sys_user` row, so it touches an identity table and never reads this one. With '
118118
+ '`OS_PLATFORM_OWNER_EMAIL` unset the first sentence is the whole truth; with it declared, '
119-
+ 'this row stops being the single point that un-makes every administrator.',
119+
+ 'this row stops being the single point that un-makes every administrator. [ADR-0131 D4] '
120+
+ "Its organization decides which row a grant's name resolves to — the grant's own "
121+
+ "organization's row, else the organization-less one — so an UNSCOPED grant reaches only "
122+
+ 'the organization-less `admin_full_access`: moving that row into an organization un-makes '
123+
+ 'every grant-derived platform admin exactly as deleting it does.',
120124
columns: [
121125
'id',
122126
'name',
123127
'active',
128+
'organization_id',
129+
'organizationId',
124130
'system_permissions',
125131
'systemPermissions',
126132
'tab_permissions',
@@ -138,11 +144,12 @@ export const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>
138144
+ 'of this legacy anchor is retired, so standing there comes from `OS_PLATFORM_OWNER_EMAIL` '
139145
+ '(§6b-config) and from nothing else, and this row is read only for the permission set it '
140146
+ 'names. Under `single` — the default — the row is unchanged and still the anchor that '
141-
+ "rig's zero-config first-user promotion mints.",
147+
+ "rig's zero-config first-user promotion mints. [ADR-0131 D4] The grant holds its set BY "
148+
+ 'NAME (`permission_set`): a grant that names nothing confers nothing, and its '
149+
+ '`permission_set_id` is not read here.',
142150
columns: [
143151
'user_id',
144-
'permission_set_id',
145-
'permissionSetId',
152+
'permission_set',
146153
'organization_id',
147154
'organizationId',
148155
'valid_from',

‎packages/core/src/security/authz-store-unavailable.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const qlEmpty = () => ({ find: async () => [] });
4343
/** A store that actually grants something, so "resolves" is read against a real grant. */
4444
const qlHealthy = () => ({
4545
find: async (object: string) => {
46-
if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps' }];
46+
if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin' }];
4747
if (object === 'sys_permission_set') {
4848
return [{ id: 'ps', name: 'pkg_admin', system_permissions: ['studio.access'] }];
4949
}

0 commit comments

Comments
 (0)