Skip to content

Commit 19bb55e

Browse files
feat(plugin-security,plugin-auth): the grant readers read the permission-set name column (ADR-0131 D4, C2 stage S5b) (#22352)
Part of #15196 Clause-②: no ADR-0131 D4, C2 stage S5b. The `domain:services` grant readers key on `sys_user_permission_set.permission_set`, the grant's permission set by name, instead of `permission_set_id`. S4a dual-writes that column and S4b backfills it. Untouched: the resolver in `@objectstack/core` (S5a), `verify` (S5c), `security-plugin.ts`, `packages/spec` and the id column (C8). Nobody's resolved permissions change. The goldens below hold that per principal, and the ablation shows the readers really read the name. ## The census's REWRITE-C2 read sites, re-read on the landed shape The base is `28bff18d0c`, which is `origin/main` at worktree creation; `fe98cc63..28bff18` touches only `packages/cli`. The census was taken at `e67ba80049`. | File · function | Census → base | Read by id at base | Read after this PR | |:--|:--|:--|:--| | `explain-engine.ts` · `collectGrantProvenance` (grants) | :510 → :510 | maps `permission_set_id` | maps `permission_set` | | `explain-engine.ts` · `collectGrantProvenance` (set rows) | :522 → :522 | set rows by id | set rows by the grant's name: own organization's row, else the organization-less one (`readGrantSetRows`) | | `delegated-admin-gate.ts` · `assertDirectGrantWrite` | :790 → :825 | pre-image's id leads to the set row | pre-image's name (delete, or an update that keeps the set); an insert or re-pointing update still reads the id it writes | | `delegated-admin-gate.ts` · `loadSetRowById` | :1238 → :1270 | the set row by id | kept for the written id only; the pre-image uses `loadSetRowForGrant` | | `bootstrap-platform-admin.ts` · `findPlatformAdminGrantHolder` legs A and B | :637 → :638, :653 → :654 | grants by the admin row's id | grants naming `admin_full_access`; leg C (new) is described under "A grant whose name is empty" | | `bootstrap-platform-admin.ts` · `findExistingPlatformAdmin` | :706 → :706 | set row by name, to get the id | unchanged (existence precondition; the id feeds leg C only) | | `bootstrap-platform-admin.ts` · `bootstrapPlatformAdmin` promote | :1133 → :1170 | writer | unchanged (writes both columns since S4a) | | `auto-org-admin-grant.ts` · `resolvePermissionSetId` | :452 → :452 | set row by name, to get the id | unchanged (the id a new grant is written with) | | `auto-org-admin-grant.ts` · `resolvePermissionSetIdsForName` | :560 → :560 | every copy's id | unchanged; feeds the unnamed id leg only | | `auto-org-admin-grant.ts` · `reconcileOrgAdminGrant` superseded / existing / revoke reads | :721, :763, :818 → :721, :763, :821 | grants by id | grants by name, plus unnamed grants by id (`grantsHoldingName`) | | `auto-org-admin-grant.ts` · `backfillOrgAdminGrants` orphan sweep | :935 → :938 | grants by id | by name, plus unnamed grants by id | | `auth-manager.ts` · `findPermissionSetRows` | :5007 → :5006 | set rows by name | unchanged (the writer's id, and `active`) | | `auth-manager.ts` · `settleSelfRegistrationGrant` existence | :5274 → :5273 | `(user, id)` | `(user, name)` | | `ensure-default-organization.ts` · `ensureDefaultOrganization` | :354, :359 → :464, :471 | set row by name, then grants by id | set row (existence) unchanged; grants naming `admin_full_access` | | `last-admin-guard.ts` · `resolveAdminUserIds` | :945, :961 → same | set rows by name, then grants by id | grants naming the set, when every row bearing the name is in effect | | `last-admin-guard.ts` · `refuseIfEmptiedRatherThanFresh` | :1137, :1151, :1180 → same | known ids, held ids, `$nin` known ids | known names, held by name, `$nin` known names | | `last-admin-guard.ts` · grant and set hooks | :1554, :1560, :1581, :1617 → same | via the enumeration | via the enumeration; `permission_set` joins `GRANT_STANDING_KEYS` | The census predates no other by-id grant read in these seven files. The other by-id reads there (`delegated-admin-gate.ts` binding write and `setsBoundToPosition`) are the position-binding junction, which C3 keeps. ## A grant whose name is empty A grant written before the column existed has no name until S4b's backfill names it. The backfill runs at `kernel:bootstrapped`. The bootstrap, the organization-admin backfill and the default-organization bind all run at `kernel:ready`, earlier (`security-plugin.ts` near :4992, :5020, :5185). On an upgraded deployment's first boot, then, every grant is still unnamed when those readers run. S4b also leaves some grants unnamed for good: dangling ids, ids on another organization's row, and names the catalog does not resolve. Each reader takes the fail-closed side: | Reader | Answer for an unnamed grant | |:--|:--| | explain provenance | reports nothing; the grant names no set | | delegated-admin gate (pre-image) | refuses the delegate (`names no permission set`); a tenant admin is not judged here | | platform-admin bootstrap | leg C reaches an unscoped human grant on the admin row through its id and **withholds** the promotion (`reason: 'admin_grant_unnamed'`, warn). It names no `adminUserId`, so `findExistingPlatformAdmin` answers `undefined` | | organization-admin reconcile | revoke reach and duplicate check still find it through its id; nothing is granted through it | | last-admin guard | counted as no administrator; an unscoped, in-window one is **evidence** in the zero-administrator path, so the write is refused rather than the bootstrap window opening | | default-organization bootstrap | `no_admin`; no decision is recorded, so a later trigger binds | | self-registration | n/a: a new user's grants are written with names | The id leg is used only to restrict (revoke, refuse a promotion, block a duplicate insert), never to confer. It goes when C8 counts the unnamed grants and drops the column. ## Goldens, recorded on the base tree, and the ablation - `plugin-security/src/grant-readers-by-name.golden.test.ts` covers explain, platform standing and organization-admin standing per principal (platform admin, organization admin, member, agent) in `single`, `group` and `isolated`. `plugin-auth/src/grant-readers-by-name.golden.test.ts` covers the last-admin guard's verdicts and the default-organization bind. - Recorded on `856ed8f1ad` (goldens only, production code at base), green there, and green with the change. - **Ablation: one grant's name column pointed at another set, with the name hooks unbound.** - Base `856ed8f1ad`: all four mutations together leave both suites green (3/3 and 3/3). Base readers read the id. - With this change, each leg is red: - member's deactivated-set grant: 3/3 red (droppedGrants loses the deactivated entry); - platform admin's grant: 1/3 red (the `single` posture: the re-run promotes, `already_have_admin` is lost); - organization admin's grant: 3/3 red (the demotion no longer revokes it); - plugin-auth, platform admin's grant: 1/3 red (`no_admin`, and the guard verdicts change). - Every leg ran through `scripts/ablation-replace.mjs` in wrap mode, and each restore was proven: blob equals HEAD and `git diff HEAD` is empty. ## Durability gate `persistGrantNameBackfillRecord` joins `DURABILITY_CRITICAL_CALLEES` (`scripts/check-durability-degradation-log-level.mjs`). The gate goes from 41 to 42 seams, all loud, and its self-test passes (63 + 57 cases). Ablation: the backfill's verdict-row catch was demoted to `warn`, and the gate went red at `grant-permission-set-name-backfill.ts:550`. Restore proven by blob. ## Verification All at head `fff0b0adb7` (main `5ff7cbe364` merged in as `e6879a88d1`) unless named. - `@objectstack/plugin-security`: vitest 185 files, 3880 passed, 45 skipped. `typecheck` exit 0; test layer 0 files / 0 errors / 0 debt. - `@objectstack/plugin-auth`: vitest 132 files, 2672 passed, 10 skipped. `typecheck` exit 0; test-layer debt unchanged at 10 files / 94 errors / 23 signatures. - Dogfood: 46 files that write or read grant rows, or that exercise these readers (closure built at `e6879a88d1`). 45 passed and 1 skipped (`rls-multitenant`'s own `skipIf`); 436 tests passed, 3 skipped. - New pins: `grant-readers-unnamed-grant.test.ts` in both packages (7 + 7), and the pre-image block in `delegated-admin-gate.test.ts` (5). - Edited gate scripts: - `pnpm check:durability-log-level` (self-test included) exit 0: 42 seams. - `measure-durability-swallow-family.mjs`: its gate-vocabulary copy gained the same name, because `check:swallow-census-controls` went red on the drift. `--self-test` and `--self-test=gated` both exit 0. - The metadata-protocol test that reads the gate script: 44/44. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 91 commands at `fff0b0adb7`, and every one exited 0, captured before any pipe. `--ran`: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm check:error-status-conformance` is in that set, and it also exited 0 when run by hand. - Lint, narrowed: `eslint --no-inline-config --format json` over the 30 changed TS/MJS files. 30 linted, 0 errors, 0 warnings, none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so untouched files' verdicts cannot move. The full `pnpm lint` is CI's. - `origin/main` is 3 commits ahead of the head. The only overlap is a comment-only hunk in `auto-org-admin-grant.ts` (PR #22331), far from this diff. ## Acceptance notes - **S5a/S5b window.** These readers answer by name, while the resolver still answers by id until S5a. The two diverge only on a grant whose columns disagree: an unnamed grant, or a named grant whose id no longer resolves. Each reader rounds toward refusing or conferring nothing, so none widens; some narrow in that window (above). - **Organization-admin reconcile.** A pair already holding the organization-admin set by name, through another organization's copy, is not handed a second grant. The dedupe still deletes only exact same-id duplicates, never a grant against another copy. - **Guard and the name column.** The guard now judges a write to `permission_set`. Clearing the last administrator's grant name is refused (pinned). With the S4a hooks bound, a cleared name was already written back. - **S4a and S4b changesets.** Their text says "no reader uses the column yet". That no longer holds for these readers. This PR's changesets say so rather than editing the earlier ones. - **Delegated-admin gate comment.** `callerOrganizationId`'s docblock now states the landed `single` behaviour (the Default Organization), per the seat's note. Comment only. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7e01fb commit 19bb55e

34 files changed

Lines changed: 1883 additions & 151 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
The permission-set grant readers in `@objectstack/plugin-auth` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4)
6+
7+
Clause-②: no
8+
9+
- **What reads the name now.** The last-administrator guard (`registerLastAdminGuard`) counts a grant-anchored platform administrator from an unscoped, in-window grant whose `permission_set` is `admin_full_access`, provided the `admin_full_access` row of that name is active. The default-organization bootstrap (`ensureDefaultOrganization`) finds the platform administrator by the same grant name. The self-registration grant checks by name whether the new user already holds the declared set. None of them reads `permission_set_id` for this any more.
10+
- **The guard treats `permission_set` as a standing column.** A write that clears the name on the last administrator's grant is refused like any other revocation. A write that re-points `permission_set_id` without a name is treated as taking the standing away, because the platform derives the new name only after the guard runs. A write that only repeats the grant's own id keeps the standing.
11+
- **A grant whose name is empty.** A grant written before the name column existed has no name until `@objectstack/plugin-security`'s one-time backfill names it at `kernel:bootstrapped`. The guard does not count such a grant as an administrator. When no administrator is counted, such a grant, if unscoped and in-window, is evidence that the environment is not a fresh install, so the write is refused instead of the bootstrap window opening. The default-organization bootstrap answers `no_admin` for it and binds no owner; that answer records no decision, so a later trigger binds once the grant has its name.
12+
- **Nothing to migrate.**
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
The permission-set grant readers in `@objectstack/plugin-security` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4)
6+
7+
Clause-②: no
8+
9+
- **What reads the name now.** The explain engine's dropped-grant provenance (`buildContextForUser`: an expired grant, or a grant of a deactivated set), the platform-admin bootstrap's existing-holder check (`bootstrapPlatformAdmin`, and the seed-ownership claim's `findExistingPlatformAdmin`), the organization-admin reconcile (`reconcileOrgAdminGrant`, `backfillOrgAdminGrants`) and the delegated-administration gate's judgement of a stored grant it is asked to change or delete. Each reads the grant's `permission_set` instead of its `permission_set_id`. Deactivation is still read from the `sys_permission_set` row, now found by that name: the grant's own organization's row, else the organization-less one. The authorization resolver in `@objectstack/core` still reads the id, and nobody's resolved permissions change.
10+
- **A grant whose name is empty.** A grant written before the name column existed has no name until the one-time backfill names it at `kernel:bootstrapped`, and the backfill leaves a grant unnamed when its id names no set row or another organization's set row. Such a grant grants nothing through these readers. Explain reports nothing for it. The organization-admin reconcile still finds it through its id when it revokes the grant or checks for a duplicate before inserting one. The delegated-administration gate refuses a delegate's change to it; a tenant administrator is not affected. The platform-admin bootstrap does not promote a second administrator while an unscoped grant on the `admin_full_access` row is still unnamed. It returns `reason: 'admin_grant_unnamed'` without an `adminUserId`, logs a warning, and the next boot reads the grant by its name.
11+
- **Within the organization-admin reconcile,** a pair that already holds the organization-admin set by name, through any organization's copy of it, gets no second grant.
12+
- The earlier release notes for the name column said no reader used it yet. That is no longer true of the readers listed above.
13+
- **Nothing to migrate.**

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import {
119119
import type { TenancyService } from './tenancy-service.js';
120120
import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js';
121121
import type { CounterStore } from './rate-limit-storage.js';
122+
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';
122123
import {
123124
isLastLocalCredentialHolder,
124125
LAST_LOCAL_CREDENTIAL_CODE,
@@ -5270,8 +5271,14 @@ export class AuthManager {
52705271
);
52715272
return;
52725273
}
5274+
// [ADR-0131 D4] Already held? Asked BY NAME — the grant's
5275+
// `permission_set`, the reference a grant keeps once its id column is
5276+
// dropped. A self-registrant is a user created moments ago, so every
5277+
// grant it holds was written with its name; any of them naming this set,
5278+
// in any organization, settles the question and no second grant is
5279+
// written.
52735280
const existing: any[] = await sys.find('sys_user_permission_set', {
5274-
where: { user_id: userId, permission_set_id: row.id },
5281+
where: { user_id: userId, [GRANT_SET_NAME_FIELD]: row.name },
52755282
limit: 1,
52765283
});
52775284
if (existing.length > 0) return;

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,7 +1319,7 @@ describe('AuthPlugin', () => {
13191319
const tables: Record<string, any[]> = {
13201320
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
13211321
sys_user_permission_set: [
1322-
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null },
1322+
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
13231323
],
13241324
sys_member: [],
13251325
sys_organization: [],
@@ -1572,7 +1572,7 @@ describe('AuthPlugin', () => {
15721572
const tables: Record<string, any[]> = {
15731573
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
15741574
sys_user_permission_set: [
1575-
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null },
1575+
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
15761576
],
15771577
// The platform admin already exists; the default-org bootstrap binds it
15781578
// as `owner` on kernel:ready. A member-less seeded user is added later.
@@ -1714,7 +1714,7 @@ describe('AuthPlugin', () => {
17141714

17151715
// The admin's grant lands: the bootstrap creates the organization, and
17161716
// that moment runs the pass — no restart, no app:seeded.
1717-
ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null });
1717+
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 });
17181718
await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' });
17191719
await vi.waitFor(() => {
17201720
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' });

‎packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,11 @@ type Row = Record<string, any>;
6565
function rig(seed: Partial<Record<string, Row[]>> = {}) {
6666
const tables: Record<string, Row[]> = {
6767
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
68-
// The platform admin `u1`, through the legacy grant anchor `single` reads.
69-
sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }],
68+
// The platform admin `u1`, through the legacy grant anchor `single` reads
69+
// — by the set's name, which every platform grant writer stores (ADR-0131 D4).
70+
sys_user_permission_set: [
71+
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
72+
],
7073
sys_organization: [],
7174
sys_member: [],
7275
sys_migration: [],

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ function makeQl(seed: Partial<Record<string, Row[]>> = {}) {
7676
const tables: Record<string, Row[]> = {
7777
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
7878
sys_user_permission_set: [
79-
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null },
79+
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
8080
],
8181
sys_member: [],
8282
sys_organization: [],
@@ -135,8 +135,8 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => {
135135
it('picks the OLDEST cross-tenant admin grant', async () => {
136136
const ql = makeQl({
137137
sys_user_permission_set: [
138-
{ id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', organization_id: null, created_at: '2026-01-02T00:00:00Z' },
139-
{ id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', organization_id: null, created_at: '2026-01-01T00:00:00Z' },
138+
{ id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-02T00:00:00Z' },
139+
{ id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-01T00:00:00Z' },
140140
],
141141
});
142142
await ensureDefaultOrganization(ql);

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core';
8383
import { postureEnforcesWall } from '@objectstack/spec/security';
8484
import { resolveTenancyPosture } from '@objectstack/types';
85+
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';
8586

8687
interface BootstrapLogger {
8788
info: (message: string, meta?: Record<string, any>) => void;
@@ -465,11 +466,18 @@ export async function ensureDefaultOrganization(
465466
if (adminPs.length === 0 || !adminPs[0].id) {
466467
return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' };
467468
}
468-
const adminPsId = adminPs[0].id;
469+
// [ADR-0131 D4] The grants NAMING the set (`permission_set`), not the
470+
// grants carrying the row's id. A grant that names nothing yet — an
471+
// upgraded deployment's, before the one-time backfill names it at
472+
// `kernel:bootstrapped` — is not read as the admin: this helper CONFERS
473+
// (an owner membership and the seeded rows), and a grant that names no set
474+
// confers nothing. The answer is then `no_admin`, which decides nothing
475+
// (`default-org-bootstrap-once.ts` records no decision on it), so a later
476+
// trigger binds once the grant names its set.
469477
const adminGrants = await tryFind(
470478
ql,
471479
'sys_user_permission_set',
472-
{ permission_set_id: adminPsId, organization_id: null },
480+
{ [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: null },
473481
50,
474482
);
475483
if (adminGrants.length === 0) {

0 commit comments

Comments
 (0)