Repository navigation
Commit aa71c4d
feat(plugin-security,plugin-auth,verify): sys_user_permission_set gains the permission-set name column, written by every grant writer (ADR-0131 C2 S4a) (#22100)
Part of #15196
Clause-②: yes (widening)
**Accepted set and read shape.** Every read of `sys_user_permission_set`
gains one field, `permission_set`. It is read-only text, at most 100
characters, and nullable. It holds the `name` of the
`sys_permission_set` row that `permission_set_id` points at, or `NULL`
on a grant written before this change.
On writes, the key used to be undeclared, and a write naming it was
refused for every caller with `400 INVALID_FIELD` (measured below). Now
a write may carry it when it equals the name derived from the row's id:
the payload's `permission_set_id`, or else the stored one. A write is
refused with `400 VALIDATION_FAILED` and `invalid_value` at
`permission_set` when the name names any other set. For a non-system
caller, a write is also refused when the id resolves to no set the
caller's organization can see. A system writer's name beside an id that
resolves nowhere is kept, because seed ordering can write the grant
before the set row exists.
A write that omits the key is accepted exactly as before, and the
platform stores the derived name. A `null` or blank value is read as
omitted. No write that was accepted before is refused now. No reader
reads the column yet, and the grant-equivalence golden below gives the
same result on the pre-change tree.
Stage S4a of ADR-0131 C2 (claim amendment `6039096736` on the card). Not
in this PR:
- no reader changes;
- no backfill of existing rows (S4b);
- no drop of the id column (C8);
- nothing in `packages/spec`;
- nothing in C4 stage 1's `plugin-auth` files (`phone-sms-texts.ts`,
`auth-plugin.ts`).
## What changes
- **`sys_user_permission_set.permission_set`** (`plugin-security`,
`objects/sys-user-permission-set.object.ts`): `Field.text`, `readonly`,
`maxLength: 100`, and not required. Rows written before this change
carry `NULL` until S4b rewrites them.
- **System-written, by two engine hooks** (`plugin-security`, new
`grant-permission-set-name.ts`, bound in `SecurityPlugin.start` and
unbound in `destroy`):
- `beforeInsert` and `beforeUpdate` on the grant object, for every
caller, system context included.
- Each write that carries `permission_set_id` gets the derived name
stamped. A supplied name that names a different set refuses the write.
- An update that writes only the name is judged against the stored id.
An update that writes neither column leaves the name alone, so no
backfill rides an unrelated edit.
- The catalog read is `ctx.api.sudo()`: the writer's own context with
`isSystem` set, never a bare `{ isSystem: true }`. So it stays inside
the writer's organization and transaction, and the answer for another
organization's set is the same as for a set that does not exist.
- The refusal never echoes the derived name.
- **Why hooks and not a middleware (measured):** on a non-system update
the engine hides the caller's value for a `readonly` column from the
hooks. It hands the value back after the hooks, and its static strip
then drops it silently.
- So the update hook judges `ctx.submitted`, which is the submission as
sent.
- The stamp has to be a hook write to survive that strip. A middleware
stamp is not recorded as one. Measured: an id change that echoes the
agreeing name lands both values only because the stamp is a hook write.
- Ablation A3 below shows what reading the payload alone misses.
- **Every writer writes both columns:**
- `auto-org-admin-grant.ts` (reconcile);
- `bootstrap-platform-admin.ts` (promote);
- `auth-manager.ts` (self-registration, which stores the set row's own
name);
- `packages/verify/src/rls.ts` (the RLS persona).
- **Docs and bookkeeping:**
- `content/docs/permissions/system-context.mdx`: the hook's `isSystem`
read is census row 23c, and the counts are regenerated by
`gen:system-context-census`.
- An ADR-0131 anchor for the new module.
- The four `plugin-security` i18n bundles are regenerated with
`check-i18n-bundles --write`, and the label and help are hand-translated
in zh-CN, ja-JP and es-ES.
- A changeset: `minor` for `plugin-security`, `plugin-auth` and
`verify`.
## The column's name: `permission_set`
ADR-0131 does not name the column. `permission_set` mirrors the sibling
assignment `sys_user_position.position`, which D3 and D4 name in the
same sentence as this one.
- Once D10 drops the id column, the grant row reads `user_id,
permission_set, organization_id`, exactly as the position assignment
reads `user_id, position, organization_id`.
- The other in-package precedent,
`sys_audience_binding_suggestion.permission_set_name`, is a suggestion
row, not an assignment.
- The width matches `sys_permission_set.name` (100), as `position`
matches `sys_position.name`.
- The label is "Permission Set Name", so it does not collide with the id
column's "Permission Set".
## Writers, measured on current `main`
The measurement round's AST census tool was re-run on `origin/main`
`3d9188502e`. These are the row writes it finds on the grant object:
| Writer | Writes | In this PR |
|:--|:--|:--|
| promote (`bootstrapPlatformAdmin`) | insert | both columns |
| reconcile (`reconcileOrgAdminGrant`) | insert, plus 3 deletes | both
columns |
| self-registration (`AuthManager.settleSelfRegistrationGrant`) | insert
| both columns |
| verify RLS persona (`provisionRlsProbePersona`) | insert | both
columns |
| `cleanup-package-permissions.ts` | delete | C7's (a delete writes no
name) |
- **Correction to the dispatch list:** `ensure-default-organization.ts`
is a reader, not a writer. It makes two reads: the admin set by name,
then the unscoped grants by id. Its only inserts are `sys_organization`
and `sys_member`. So S4a does not change it; it is a REWRITE-C2 reader
for S5b.
- **Writers outside the list:**
- the data door (Setup, delegated-admin direct grants, any API client);
- any system writer outside this repository (seed datasets, a
deployment's own code);
- the dogfood harness's own grant inserts.
All of them write the id alone, and the hook stamps the name. The
dogfood run below includes a file whose system inserts carry no name.
## What the write door does with a supplied name (measured, real engine,
SQL driver, real `SecurityPlugin`)
| Write | Pre-change tree (no column) | Column, hooks unbound | This PR
|
|:--|:--|:--|:--|
| insert naming the key, non-system | `400 INVALID_FIELD` | stored
verbatim, even when it names another set | stamped if omitted; agreeing
kept; otherwise `400 VALIDATION_FAILED` |
| insert naming the key, system | `400 INVALID_FIELD` | stored verbatim
| same as above; an unresolvable id keeps the name |
| update, name only, non-system | refused (undeclared) | dropped
silently | `400` unless it agrees with the stored id |
| update, name only, system | refused (undeclared) | stored | `400`
unless it agrees |
| id written alone | no column | name `NULL` | derived name stamped |
The first column comes from a pre-change-tree probe: the object file and
`security-plugin.ts` were restored to `3d9188502e` by blob, and the
write was refused `400 INVALID_FIELD` for both callers. The second
column comes from a probe on this branch with the hooks unregistered.
Both probes were scratch files, restored by blob hash.
## Pins, and the ablation for each (all at head `870717aca6`)
| Leg | Mutation (through `scripts/ablation-replace.mjs`, wrap mode,
restored and proven by blob) | Red |
|:--|:--|:--|
| A1 | the client's disagreeing name is put through instead of refused |
5 of 18 in `grant-permission-set-name.test.ts`: insert, batch, stale old
name, name-only update, system writer |
| A2 | the unresolvable-id branch accepts the client's name | 2 of 18: a
name beside an id that names no set; another organization's set id |
| A3 | the update hook ignores `ctx.submitted` | 2 of 18: stale old
name, name-only update. The engine had hidden the value, so the write
passed silently |
| S1 | no stamp | 10 of 18 |
| W1 | promote omits the name |
`grant-permission-set-name.writers.test.ts`: promotion |
| W2 | reconcile omits the name | writers test: reconcile, both postures
|
| W3 | self-registration omits the name | `plugin-auth`
`audience-posture.test.ts`: the new pin |
| W4 | the RLS persona omits the name | `verify`
`rls-persona-grant.test.ts`: both branches (set found, set created) |
| G1 | reconcile writes the wrong set's name |
`grant-permission-set-name.equivalence.test.ts`, both postures. The hook
refuses the system write, the grant never lands, and the suite turns red
at the reconcile's own outcome, ahead of the golden |
The writer pins measure the writer's own payload. The `plugin-security`
writer tests use a real engine with no `SecurityPlugin`, and the
`plugin-auth` and `verify` doubles store what they are handed, so no
hook fills a name the writer left out. Every leg's restore is proven by
blob equal to `HEAD` and an empty `git diff HEAD`. The first A3 attempt
was refused by the tool, because the replacement contained the anchor.
It was re-anchored and re-run, and it is the A3 row above.
## Grant equivalence (no principal's grants change)
`grant-permission-set-name.equivalence.test.ts` resolves four principals
through the real writers and the real resolver
(`resolveUserAuthzGrants`), in `single` and `isolated`:
- platform administrator: the `single` promotion, or the walled config
owner;
- organization administrator: the reconcile;
- member: a set granted through the data door;
- agent: an OAuth agent acting for the organization administrator with
the actions consent.
The whole envelope is compared with sorted arrays: positions,
permissions, system permissions, tab permissions, posture and
organization reach.
- **Pre-change leg:** the four changed `plugin-security` sources were
restored to `3d9188502e` by blob, and the new module was absent. Result
2/2 green.
- **This head:** 2/2 green.
## Verification
The dependency closure was built first. `870717aca6` adds only the ADR
anchor JSON on top of `633a4c534c`, and the `plugin-security` non-test
sources are unchanged since `0abc83cdde`.
- `plugin-security`:
- `vitest run` at `0abc83cdde`: 172 files, 3663 passed and 45 skipped.
- The three new suites, re-run at `870717aca6`: 23/23.
- `typecheck` at `633a4c534c` is green, and the test layer compiles with
0 debt.
- `plugin-auth`:
- `vitest run` at `870717aca6`: 126 files, 2613 passed and 10 skipped.
- The earlier run at `e6d2b91a1d` had 1 red: a minimal test fixture that
declares the grant object without the new column. The fixture now
declares it.
- `typecheck` is green, with the test-layer debt ledger unchanged at 10
files / 94 errors.
- `verify`: `vitest run` at `633a4c534c`, 18 files, 133 passed;
`typecheck` green.
- Dogfood at `633a4c534c`, the files that sign up, promote or write
grants: 11 files, 84 passed and 2 skipped. The skips are the suite's own
`skipIf(!organizationsAvailable)`, in `rls-multitenant`. The files:
- `admin-platform-admin-standing`
- `org-admin-affordance-reach`
- `showcase-permission-zoo`
- `membership-actor-attribution`
- `me-apps-and-everyone-baseline`
- `showcase-permission-seeding`
- `rls-runner`
- `rls-fixture`
- `rls-multitenant`
- `showcase-client-liaison-fixtures`
- `invitation-ledger-row-scope`
- Gates at `870717aca6`: `dispatch-gates --commands` was re-derived at
this head (107 commands). Each one was run with its exit code captured
before any pipe: 107 of 107 exited 0, plus `check:i18n-coverage` (exit
0). `dispatch-gates --ran` reconciles: 107 derived, 107 run, 0 not
measured, 0 unrun. The 54 commands derived at dispatch are a subset of
these.
- Lint: `eslint --no-inline-config --format json` over the 17 changed TS
files: 17 files linted, 0 errors, 0 warnings, none ignored. The
population is the 17 TS paths in `git diff 3d91885`. The repo config
enables no type-aware linting (no `parserOptions.project`) and no
cross-file rule; it reads only two baseline JSONs, both unchanged. So a
change to these files cannot move the result for an untouched file. The
full `pnpm lint` run is CI's.
## Acceptance notes
- The Setup related list on `sys_user.page.ts` and the record title
(`display_title`) still show `permission_set_id`. S4a makes no reader
change; S5 and C9 own those.
- No index on `permission_set` yet. S5's readers bring it with the reads
that need it.
- A `sys_permission_set` row renamed at engine level would leave its
grants naming the old name. The data door refuses renames (ADR-0094),
the projector upserts by name, and no in-repo writer renames a set row.
The S4b backfill's verification is where a mismatch would show.
- A platform writer that names the wrong set is refused by the hook, and
the reconcile's `tryInsert` reports it as `skipped` (ablation G1). The
writer pins are what keep that from shipping.
- Test fixtures that declare their own minimal grant object: one in
`plugin-auth` drove the self-registration writer and now declares the
column. Eleven others (in `client`, `runtime`, `plugin-approvals` and
`qa/http-conformance`) drive no grant writer: none of those tests
composes `SecurityPlugin` or a self-registration set.
- `security-plugin.ts` is touched for the registration only (18 lines),
outside the claim amendment's file list, because the data door is a
writer outside the census list.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ac9f8bd commit aa71c4d
20 files changed
Lines changed: 1369 additions & 9 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- plugins
- plugin-auth/src
- plugin-security/src
- objects
- translations
- verify/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 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
| 130 | + | |
130 | 131 | | |
131 | 132 | | |
132 | 133 | | |
| |||
138 | 139 | | |
139 | 140 | | |
140 | 141 | | |
141 | | - | |
| 142 | + | |
142 | 143 | | |
143 | 144 | | |
144 | 145 | | |
| |||
281 | 282 | | |
282 | 283 | | |
283 | 284 | | |
284 | | - | |
| 285 | + | |
285 | 286 | | |
286 | 287 | | |
287 | 288 | | |
| |||
355 | 356 | | |
356 | 357 | | |
357 | 358 | | |
358 | | - | |
| 359 | + | |
359 | 360 | | |
360 | 361 | | |
361 | 362 | | |
362 | | - | |
363 | | - | |
| 363 | + | |
| 364 | + | |
364 | 365 | | |
365 | 366 | | |
366 | | - | |
367 | | - | |
| 367 | + | |
| 368 | + | |
368 | 369 | | |
369 | 370 | | |
370 | 371 | | |
| |||
428 | 429 | | |
429 | 430 | | |
430 | 431 | | |
431 | | - | |
| 432 | + | |
432 | 433 | | |
433 | 434 | | |
434 | 435 | | |
| |||
Lines changed: 21 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
747 | 747 | | |
748 | 748 | | |
749 | 749 | | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
750 | 771 | | |
751 | 772 | | |
752 | 773 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5281 | 5281 | | |
5282 | 5282 | | |
5283 | 5283 | | |
| 5284 | + | |
| 5285 | + | |
| 5286 | + | |
| 5287 | + | |
| 5288 | + | |
5284 | 5289 | | |
5285 | 5290 | | |
5286 | 5291 | | |
| |||
Lines changed: 3 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
93 | 96 | | |
94 | 97 | | |
95 | 98 | | |
| |||
Lines changed: 3 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
781 | 781 | | |
782 | 782 | | |
783 | 783 | | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
784 | 787 | | |
785 | 788 | | |
786 | 789 | | |
| |||
Lines changed: 3 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1134 | 1134 | | |
1135 | 1135 | | |
1136 | 1136 | | |
| 1137 | + | |
| 1138 | + | |
| 1139 | + | |
1137 | 1140 | | |
1138 | 1141 | | |
1139 | 1142 | | |
| |||
0 commit comments