Repository navigation
Commit 7d7943d
feat(plugin-security): grants stored before the permission-set name column get their name, once, at boot (ADR-0131 C2 S4b) (#22143)
Part of #15196
Clause-②: no
**What this does.** Grants stored in `sys_user_permission_set` before
the `permission_set` name column existed carry `NULL` there. On boot,
`@objectstack/plugin-security` now fills that column once on each such
grant. The value is the `name` of the `sys_permission_set` row that the
grant's own `permission_set_id` names, read inside the grant's own
organization and checked through the security catalog read before it is
written. A grant this pass cannot name keeps `NULL` and is reported in
the boot log. The pass records its verdict once in `sys_migration`.
**Clause-② is `no`:** the column and its rule (system-written, agreeing
with the id) already shipped with stage S4a. This pass brings older rows
under that rule. No accepted or refused input changes, no reader reads
the name yet, and no surface is added: the module is not exported from
the package.
Stage S4b of ADR-0131 C2 (claim amendment `6049507458` on the card). Not
in this PR:
- no reader changes (S5);
- no change to the S4a hooks or to the object definition;
- no drop of the id column (C8);
- nothing in `packages/spec`, nothing in `plugin-auth`;
- no row deleted, no grant moved.
## What changes
- **`grant-permission-set-name-backfill.ts`** (new, `plugin-security`).
For every grant whose name is `NULL` or blank (the whole set is read,
ordered by id, before anything is written):
1. **The set row its id names, read inside the grant's wall.** The read
runs under the grant's organization (`seedCtx`), so the driver's tenant
scope returns that organization's rows and the organization-less ones.
Then the resolver's own grant rule is asked of the set row: an
organization-less set applies everywhere, and an organization's set
applies only to a grant of that same organization. So an
organization-less grant may name only an organization-less set.
2. **The name is verified through S1's catalog read**
(`createSecurityCatalogReader` from `@objectstack/core`). A name the
catalog does not resolve is not written.
3. **Written as a system update of the name column alone,** under the
grant's organization.
- **Report, never guess.** Each category below is logged once, with a
count and grant row ids only. No name or id from another organization is
logged.
- an id that names no set row: `warn`;
- an id whose set row is outside the grant's organization: `warn`;
- a set row whose name the catalog read does not resolve: `error`, with
the remedy;
- a name write that did not land: `error`;
- a read that did not happen (the grant scan, a set row, the catalog's
`AuthzStoreUnavailableError`): the pass stops and says which read
failed. It never reads an outage as "no such set".
- **Once, and remembered.** The ledger row follows `plugin-auth`'s
`membership-backfill-ledger.ts`, which this PR reads but does not edit:
- one row with its own id,
`adr-0131-grant-permission-set-name-backfill`;
- `verified_at: null`, since nothing gates on it;
- `blocking: 0`;
- `details` holds the counts.
- A verdict is recorded only when a later pass could not decide
differently: everything was named, or names no set row, or names a set
outside the grant's organization. An unresolved name, a failed write or
a stopped pass leaves it unrecorded, so the next boot judges again. A
concurrent boot that lands the same id first counts as recorded.
- **One departure from that module, on purpose:** without a readable
ledger, this pass still runs. That module does not run without its
ledger, because its second run would decide membership again. This pass
only fills a `NULL` with the name the row's own id already names, so a
second run writes nothing new. The cost is one scan per boot, and a
`warn` that says so.
- **Boot wiring** in `SecurityPlugin.start`, beside the platform
bootstrap (near `:4816`; disjoint from stage S2's manifest edit near
`:1448`). It runs at `kernel:bootstrapped`, inside a `try` that only
warns, so the boot never fails on it.
- Outside the claimed file list, both mechanical:
- `scripts/adr-anchors/…grant-permission-set-name-backfill.ts.json`
anchors the module to ADR-0131.
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` were
regenerated with `tenant-audit-census --write`. The module adds two
engine write call sites, so the count goes from main's 232 to 234, and
the page's prose figures follow. They were regenerated again on the
merge of `main` at `aa9447c70b`, whose own regeneration had moved the
base from 233 to 232.
## The write path, and why (measured)
Measured on a real `ObjectQL` engine over `SqlDriver` with the real
`SecurityPlugin` started, under `single` and `isolated`. The S4a hooks
run on the backfill's system write. Results:
| system update under the grant's organization | result |
|---|---|
| `{ id, permission_set: OTHER_SET_NAME }` | refused,
`VALIDATION_FAILED` |
| `{ id, permission_set: AGREEING_NAME }` (this PR's write) | lands |
| `{ id, permission_set_id: SAME_ID }`, no name | the hook stamps the
name |
| `{ id, OTHER_COLUMN: VALUE }` | name stays `NULL` |
So the hooks fill the name by themselves only on a write that carries
the id. This PR writes the verified name, not an id echo, for three
reasons:
- Two independent checks agree before a name lands: the catalog read
here, and the hook's re-derivation from the stored id. A grant whose id
moved between this pass's read and its write is refused, not
mislabelled.
- The write carries no `permission_set_id`. So nothing keyed on that
column re-judges the grant: `plugin-auth`'s last-administrator guard
lists it in `GRANT_STANDING_KEYS`.
- The name lands with the hooks unbound too (pinned).
**The hook is not this pass's wall.** For a system write whose stored id
the hook cannot resolve inside the writer's wall, the hook stands down
and lets the value land. Ablation A2b below shows that a name carried
across organizations lands through the hook.
## The tenant wall, against today's resolver (measured)
The grant rule: `resolveUserAuthzGrants` was called for a user holding
one grant per case, with the active organization set to the grant's
organization (A), to another organization (B), or to none. The same in
`single`, `group` and `isolated`:
| grant org → set row org | resolver today: A / B / none | this pass |
|---|---|---|
| A → A | grants / – / – | names it |
| A → organization-less | grants / – / – | names it |
| organization-less → organization-less | grants / grants / grants |
names it |
| organization-less → A | grants / grants / grants | reported, `NULL` |
| A → B | grants / – / – | reported, `NULL` |
Inside the wall the pass matches today's resolution exactly. The last
two rows differ: today's resolver reads a grant's set row by id without
a wall (`resolve-authz-context.ts` §6b), so those grants grant across
organizations by id. This order's rule is that a name never crosses
organizations, so those grants are reported and left unnamed. Readers
still read the id, so their grants do not move here. What they resolve
to once readers switch to the name is S5's question (Acceptance notes).
## When it runs (measured)
At `kernel:bootstrapped`: the kernel fires it only after every
`kernel:ready` handler has settled, on `ObjectKernel` and on
`LiteKernel`. Code-declared and environment catalog items are in the
registry by `kernel:ready`. A real `LiteKernel` boot is pinned:
- a provider plugin whose `kernel:ready` handler registers a permission
set after `SecurityPlugin`'s own handler;
- a grant on that set, stored before the boot.
The grant is named on the same boot, and the verdict is recorded. With
the wiring moved to `kernel:ready` (A6), the name reads missing.
A definition that arrives after the boot, such as a package installed
into the running process, cannot turn into a recorded "missing". The
unresolved name is reported at `error`, the verdict stays unrecorded,
and the next boot judges it again (pinned).
## Pins
All pins are in `grant-permission-set-name-backfill.test.ts`, on a real
engine with the real plugin:
- Every backfilled name equals the set row its id names and resolves
through S1's read, in `single` and `isolated`.
- **Grant equivalence:** the platform administrator, organization
administrator, member and agent resolve to the S4a golden before and
after the pass, in two postures.
- An id with no set row is reported by count and grant id, and no name
is guessed.
- A catalog read that answers nothing: no name is written, one `error`,
and the verdict is not recorded.
- An unresolved-yet name: no record; after registration, the next pass
names it.
- A failed name write: `error`, and no record.
- A cross-organization id is not written, and nothing of that
organization is logged. Two postures.
- A second pass reads and writes nothing, and the ledger row is written
once.
- No ledger: the pass still names, warns, and a second pass renames
nothing.
- The name lands with the S4a hooks unbound.
- The real-kernel late-provider boot, and the boot wiring.
**Ablations.** All legs ran at `06642523e1` through
`scripts/ablation-replace.mjs` in wrap mode. Each restore was proven by
blob equal to `HEAD`, with an empty `git diff HEAD`. The subject is
imported from `src` by a relative path, so no `dist/` leg applies.
| leg | mutation | result |
|---|---|---|
| A1 | catalog check always passes | red 2/4 (`report, never guess`):
silent catalog, unresolved-yet |
| A2 | grant rule dropped, driver wall kept | red 2/2
(cross-organization, both postures): organization-less grants named |
| A2b | grant rule dropped and set-row read unwalled | red 2/2: all
three cross-organization grants named |
| A3 | ledger read skipped | red 2/2 (`once, and remembered`): second
pass returns `ran` instead of `already-run` |
| A4 | an id with no set row gets the id as its name | red 1/4: expected
`'ps_gone'` to be `null` |
| A5 | the write also sets `valid_until` in the past | red 2/2: the
grant-equivalence golden |
| A6 | wiring moved to `kernel:ready` | red 1/1: the late provider's set
reads missing |
## Verification
Head `7f9500afd5`. That is `main` at `8fc50b7647` merged in, plus this
PR.
- **This file:** 15/15. `plugin-security` typecheck is green, test layer
included: 0 files, 0 errors, 0 debt.
- **Full `plugin-security` vitest after the merge:** 173 files, 3678
passed, 45 skipped. Its sources are byte-identical to the head except
the typed test reads, which are re-run in this file.
- **`core`:** typecheck green, and vitest `--project local` 78 files,
2192 passed. The closure was rebuilt first.
- **Dogfood, 7 files, 64 passed and 2 skipped** (`rls-multitenant`'s own
`skipIf`). The files: `security-catalog-showcase` (its walled arm
included), `membership-ended-session-revoke`,
`org-admin-affordance-reach`, `admin-platform-admin-standing`,
`me-apps-and-everyone-baseline`, `showcase-permission-seeding` and
`rls-multitenant`. All 8 real showcase boots ran the pass and recorded
it, with `{"unnamed":0,"named":0,"dangling":0,"crossOrganization":0}`: a
fresh boot's writers write both columns.
- **Gates:** `dispatch-gates --commands` derived 104 at this head, and
all 104 exited 0. `dispatch-gates --ran`: 104 run, 0 NOT-MEASURED, 0
UNRUN.
- First-pass reds, fixed: `check:tenant-audit-census` (regenerated,
above) and `check:query-options-erasure`. For the second, ten test reads
had erased their options to `any`; they are now typed, and the test
surface is back to 236.
- Two `PREREQUISITE NOT MET` refusals (exit 3) were re-run after
building their packages, and both exit 0.
- **Lint, a narrowed run.** `eslint --no-inline-config --format json`
over the 3 changed TS files: 3 linted, 0 errors, 0 warnings, none
ignored. The config sets no `parserOptions.project` and no typed rule,
so untouched files' verdicts are invariant. The full `pnpm lint` is
CI's.
- **Patch round, head `0306ca272a`** (`main` at `aa9447c70b` merged; no
rebase, no force-push). The two census files conflicted with the
regeneration that landed on `main`. They were resolved by regenerating
with `tenant-audit-census --write` from `main`'s copy, and the prose
figures were updated from the gate's own numbers: write sites 232 to
234, run-time names 78 to 80, decidably elevated 122 to 123, elevation
undecidable 102 to 103. No side was hand-picked. At this head:
- `check-tenant-audit-census`, `check-system-context-census` and
`check-regen-pending` all exit 0; no os-regen path is touched and none
is pending.
- This file passes 15/15, and `plugin-security` typecheck is green after
a closure rebuild.
- `dispatch-gates --commands` derived the same 104 commands. All 104
exit 0 after rebuilding the packages the dist-reading gates read, and
`--ran` reports 104 run, 0 NOT-MEASURED, 0 UNRUN.
- **Main moved since `aa9447c70b`:** six commits. One of them (#22173)
edits `security-plugin.ts` in other regions, and `git merge-tree`
against it is clean. The boot refusal it adds asks for `sys_user` and
`sys_member`, which the pins' engine registers. The joint build is CI's
and the queue's.
## Acceptance notes
- **Grants whose id names a set row outside their organization grant
today by id.** The resolver's set-row read (§6b) has no wall, measured
in the table above. This PR leaves those grants unnamed and lists them
at boot. The reader switch will meet them: it must decide what they
resolve to, and pin it. Carrier: S5a (`resolve-authz-context.ts` §6).
- **A grant a system writer leaves unnamed after the ledger row lands is
never backfilled.** The S4a hook stands down for a system write whose id
resolves to nothing yet, and this pass is one-time. No writer in this
repository leaves one (all four write both columns). D10's "verified
rewrite" before the id column is dropped must count `NULL` names, not
trust this ledger row. Carrier: C8.
- **`persistGrantNameBackfillRecord` is a new durability seam.** It logs
at `error` and is pinned, but it is not in
`DURABILITY_CRITICAL_CALLEES`, because that edit is outside this stage's
surface. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 13aea18 commit 7d7943d
7 files changed
Lines changed: 1403 additions & 25 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages/plugins/plugin-security/src
- scripts/adr-anchors
| 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 | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
191 | 191 | | |
192 | | - | |
193 | | - | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
| 226 | + | |
227 | 227 | | |
228 | | - | |
| 228 | + | |
229 | 229 | | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
| 236 | + | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | | - | |
| 250 | + | |
251 | 251 | | |
252 | 252 | | |
253 | 253 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
| 304 | + | |
| 305 | + | |
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
| 36 | + | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
| 97 | + | |
| 98 | + | |
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
| |||
167 | 167 | | |
168 | 168 | | |
169 | 169 | | |
| 170 | + | |
| 171 | + | |
170 | 172 | | |
171 | 173 | | |
172 | 174 | | |
| |||
0 commit comments