Repository navigation
Commit 19bb55e
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
File tree
- .changeset
- packages/plugins
- plugin-auth/src
- plugin-security/src
- objects
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
119 | 119 | | |
120 | 120 | | |
121 | 121 | | |
| 122 | + | |
122 | 123 | | |
123 | 124 | | |
124 | 125 | | |
| |||
5270 | 5271 | | |
5271 | 5272 | | |
5272 | 5273 | | |
| 5274 | + | |
| 5275 | + | |
| 5276 | + | |
| 5277 | + | |
| 5278 | + | |
| 5279 | + | |
5273 | 5280 | | |
5274 | | - | |
| 5281 | + | |
5275 | 5282 | | |
5276 | 5283 | | |
5277 | 5284 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1319 | 1319 | | |
1320 | 1320 | | |
1321 | 1321 | | |
1322 | | - | |
| 1322 | + | |
1323 | 1323 | | |
1324 | 1324 | | |
1325 | 1325 | | |
| |||
1572 | 1572 | | |
1573 | 1573 | | |
1574 | 1574 | | |
1575 | | - | |
| 1575 | + | |
1576 | 1576 | | |
1577 | 1577 | | |
1578 | 1578 | | |
| |||
1714 | 1714 | | |
1715 | 1715 | | |
1716 | 1716 | | |
1717 | | - | |
| 1717 | + | |
1718 | 1718 | | |
1719 | 1719 | | |
1720 | 1720 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | | - | |
69 | | - | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
70 | 73 | | |
71 | 74 | | |
72 | 75 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
| 79 | + | |
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
| |||
135 | 135 | | |
136 | 136 | | |
137 | 137 | | |
138 | | - | |
139 | | - | |
| 138 | + | |
| 139 | + | |
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
85 | 86 | | |
86 | 87 | | |
87 | 88 | | |
| |||
465 | 466 | | |
466 | 467 | | |
467 | 468 | | |
468 | | - | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
469 | 477 | | |
470 | 478 | | |
471 | 479 | | |
472 | | - | |
| 480 | + | |
473 | 481 | | |
474 | 482 | | |
475 | 483 | | |
| |||
0 commit comments