Repository navigation
Commit ffb31fc
fix(plugin-security)!: granted_by is provenance — the delegated-admin gate stamps the writer on every non-system insert, and the column is readonly (#22243)
Fixes #22201
Clause-②: no (narrowing)
`granted_by` on `sys_user_permission_set` and `sys_user_position`
records who wrote a grant. This PR enforces that, following the seat's
ruling A in the claim:
- Both columns are now `readonly: true`.
- `DelegatedAdminGate.assert` first runs the authority decision
(`authorize`, the old body). It then stamps the caller's `userId` into
`granted_by` on every non-system insert it admits into either table,
whatever the payload carried. Tenant-level admins, scoped delegates and
self-delegations are stamped alike. A refused write throws before the
stamp.
- This one stamp (`stampWriter`) replaces the three old conditional
stamps. Those ran on the delegate and self-delegation paths only, and
only when the value was null.
- System-context writers are unchanged. `isSystem` short-circuits the
security middleware before the gate.
The change does not widen who may grant or what a grant gives. It
touches nothing in `packages/objectql` or `packages/spec`, and it writes
no data.
## Measured: stored `granted_by` per writer case
All cases ran on a real `ObjectQL` engine over `SqlDriver`
(better-sqlite3), with the real `SecurityPlugin` middleware, and so the
real gate, in front. OTHER is another existing `sys_user` that the
caller names as the granter.
- **Before** = `origin/main` 3b49318 source, read with the rig at
295745e.
- **Readonly only** = option B: `readonly: true`, gate unchanged.
- **After** = this branch.
| writer case | table | before | readonly only | after |
|---|---|---|---|---|
| tenant-level admin, names OTHER | both | OTHER | OTHER | writer |
| tenant-level admin, names none | both | null | null | writer |
| scoped delegate, names OTHER | both | OTHER | OTHER | writer |
| scoped delegate, names none | both | writer | writer | writer |
| system-context insert, names OTHER | both | OTHER | OTHER | OTHER |
| invitation placement `apply` (system) | `sys_user_position` | issuer |
issuer | issuer |
| organization-admin reconcile, triggered by a system `sys_member` write
attributed to a human | `sys_user_permission_set` | that human | that
human | that human |
| non-system UPDATE renaming the granter (admin or delegate) | both |
lands: OTHER | stripped: unchanged | stripped: unchanged |
**Mechanism finding (premise correction).** Readonly alone does not
change what an insert stores. On insert, the engine's create-side static
readonly strip skips every `sys_` object
(`staticReadonlyInsertSubject`). The card's premise was that "an
explicit caller value … is stripped by `stripReadonlyFields`, so the row
lands NULL". That holds on update only. On insert, the caller's value
lands as sent. The gate's in-place middleware stamp is therefore what
lands, and #14088's hook-write record is not involved. The update strip
does judge `sys_` objects, and that is what closes the update door.
## Measured: audit bucket per case
(`ObjectQL.inspectDanglingReferences`)
| row | readonly removed (ablation B1/B2) | after |
|---|---|---|
| `granted_by` names an id with no `sys_user` row | `dangling` |
`provenance`; no `[integrity]` warning logged |
| control: `user_id` names an id with no `sys_user` row | `dangling` |
`dangling`; exactly one `[integrity]` warning |
Both tables are covered.
## Family, measured one by one
- **`sys_user_position.granted_by`: same writer class, same change.**
Its non-system writes go through the gate, for both tenant-level admins
and delegates (including self-delegation). Its system writer, invitation
placement, sets the issuer.
- **`sys_record_share.granted_by` (plugin-sharing): a different class,
left unchanged.**
- The object is `managedBy: 'engine-owned'`, so the ADR-0103
engine-owned guard refuses a user-context generic write.
- Its one writer is the sharing service's grant. That writer inserts
under a system context and stamps `granted_by` from the acting context's
`userId`, which is null for a rule reconcile.
- `GrantShareInput` carries no granter, so no caller can name one.
"granted_by = writer" already holds there.
## Tests
- **`granted-by-writer-provenance.test.ts` (new, 20 cases).** Each runs
on the real engine with the real `SecurityPlugin`.
- Non-system inserts store the writer: 2 tables × 2 callers × named or
absent.
- System writers keep their value: the plain system insert, invitation
placement and the organization-admin reconcile.
- A non-system update cannot rewrite the granter: 2 tables × 2 callers.
- The audit files a deleted granter under `provenance` with no warning,
beside the business-lookup control that lands in `dangling` and warns:
per table.
- **`delegated-admin-gate.test.ts` (+16 cases).** These pin the gate's
own half:
- named or absent granter, per principal and table;
- batch inserts;
- a refused insert is not stamped;
- an update is not stamped;
- a self-delegation that names another granter;
- `sys_member` and `sys_position_permission_set` never gain the key.
- **`write-preview-field-gate-parity.test.ts` (fixture).** The suite's
`sys_user_position` stand-in now declares `granted_by`, as the shipped
object does. The gate now stamps a tenant-level admin's insert, and the
stand-in's missing column was refused as an unknown field before the
rule that suite pins was reached.
- **The platform-admin promotion.** `bootstrap-platform-admin.ts` is
read only here (PR #22214 is in flight), so it is not called. Its path,
a plain `{ isSystem: true }` insert, is the system-insert pin.
## Ablations
Each mutation went to disk through `scripts/ablation-replace.mjs`, with
the anchor hitting exactly once and the blob changing. Each restore was
proven by the blob hash equalling the HEAD blob and `git diff HEAD`
being empty. The subject resolves through relative source imports, with
`@objectstack/objectql` aliased to source, so no `dist/` was in the
path.
| ablation | mutation | red / green | restore |
|---|---|---|---|
| A | `stampWriter` keeps a supplied value (`granted_by == null` guard)
| 11 red (every "names another user" pin, real-engine and unit) / 92
green | blob 8fb5c41 == HEAD |
| B1 | `sys_user_permission_set.granted_by` `readonly: false` | 3 red (2
update pins, provenance pin: the row lands in `dangling`) / 17 green |
blob 2bed0a5 == HEAD |
| B2 | `sys_user_position.granted_by` `readonly: false` | 3 red (same
three, for that table) / 17 green | blob 9df5977 == HEAD |
| C | `stampWriter` call removed | 22 red (11 named, 8 absent, 3 legacy
stamp pins) / 81 green | blob 8fb5c41 == HEAD |
The first B1 attempt was a no-op. The replacement was a substring of the
anchor, so the tool refused (`replace x1 -> x1`), and the command never
ran. It was re-run with `readonly: false`, and the table above shows
that run.
## Validation
All readings below were taken at `b4ed57b0ad`, after merging
`origin/main` (0e9371f).
- **plugin-security tests.** `vitest run --maxWorkers=2` (whole
package): 177 files, 3766 passed, 45 skipped, exit 0.
- **plugin-security typecheck.** `pnpm --filter
@objectstack/plugin-security typecheck`: exit 0. That covers the build
program, the scripts program and `check:test-typecheck`, which reports 0
errors. `tsc --listFiles` over `tsconfig.test.json` lists all three
touched test files.
- **Build.** `turbo run build --filter=!@objectstack/docs`: 72 of 72
tasks succeeded.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derives 65 commands. All 65
ran, plus `pnpm --filter @objectstack/spec check:generated`, and every
one exited 0.
- `--ran` reconciliation, with an exit code recorded per command: 65
derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
- `check-adr-0087-registration`: green, disposition `not-required
(no-migration-prescription)`.
- `check-changeset-no-major`: green, "This diff introduces no `major`
bump". The level axis needs a PR payload, so it is left to CI.
- `check:engine-double-contract`: green.
- `check:i18n`: green, 9 packages in sync.
- `check:dual-build-cjs-loads`: green, 106 entry points across 66
packages.
- **ESLint, narrowed to the changed files.**
- The 6 changed `.ts` files ran under the repo's own `eslint.config.mjs`
with `--no-inline-config`. The JSON report lists 6 files, with 0 errors
and 0 warnings, so none was ignored.
- That config enables no type-aware linting (no `parserOptions.project`,
no typed rules; see its own comment), so this diff cannot change the
verdict on any file it does not touch.
- The repo-wide `pnpm lint` is left to CI.
## Acceptance notes
- **The supplied value is replaced, not refused.** A non-system insert
that names a granter gets the writer, which shows in the returned row.
On insert there is no `droppedFields` entry or strip warning for it,
because the create-side strip does not run on `sys_` objects.
- **One description is now incomplete.** `sys_user_position.granted_by`
still says "(stamped by the delegated-admin gate for delegate writes)".
That is still true, but it no longer covers every case. Rewording it
would re-key the translated bundles in four locales, so it is left
alone. The docs pages under `content/docs/permissions/` describe the
delegate stamp, which also still holds.
- **No stored row is rewritten.** The production row's sentinel is out
of scope (triage ruling); clearing it is cloud's maintainer's write.
- **Dogfood is declared to CI and was not run locally.** The dogfood
suites that read `granted_by` expect the writer or a truthy value
(`delegation-of-duty`, `showcase-permission-zoo`,
`membership-actor-attribution`), which this change keeps.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 31d24a5 commit ffb31fc
7 files changed
Lines changed: 481 additions & 17 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
- objects
| 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 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | | - | |
| 115 | + | |
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| |||
979 | 979 | | |
980 | 980 | | |
981 | 981 | | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
| 998 | + | |
| 999 | + | |
| 1000 | + | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
| 1011 | + | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
| 1035 | + | |
| 1036 | + | |
| 1037 | + | |
| 1038 | + | |
| 1039 | + | |
| 1040 | + | |
| 1041 | + | |
| 1042 | + | |
| 1043 | + | |
| 1044 | + | |
| 1045 | + | |
| 1046 | + | |
| 1047 | + | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
151 | 185 | | |
152 | 186 | | |
153 | 187 | | |
| |||
242 | 276 | | |
243 | 277 | | |
244 | 278 | | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
245 | 287 | | |
246 | 288 | | |
247 | 289 | | |
| |||
346 | 388 | | |
347 | 389 | | |
348 | 390 | | |
349 | | - | |
| 391 | + | |
350 | 392 | | |
351 | | - | |
| 393 | + | |
352 | 394 | | |
353 | 395 | | |
354 | 396 | | |
| |||
536 | 578 | | |
537 | 579 | | |
538 | 580 | | |
539 | | - | |
540 | | - | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
541 | 584 | | |
542 | 585 | | |
543 | 586 | | |
| |||
640 | 683 | | |
641 | 684 | | |
642 | 685 | | |
643 | | - | |
644 | | - | |
645 | | - | |
646 | 686 | | |
647 | 687 | | |
648 | 688 | | |
| |||
737 | 777 | | |
738 | 778 | | |
739 | 779 | | |
740 | | - | |
741 | 780 | | |
742 | 781 | | |
743 | 782 | | |
| |||
777 | 816 | | |
778 | 817 | | |
779 | 818 | | |
780 | | - | |
781 | | - | |
782 | | - | |
783 | | - | |
784 | 819 | | |
785 | 820 | | |
786 | 821 | | |
787 | 822 | | |
788 | 823 | | |
789 | | - | |
| 824 | + | |
790 | 825 | | |
791 | 826 | | |
792 | 827 | | |
| |||
817 | 852 | | |
818 | 853 | | |
819 | 854 | | |
820 | | - | |
821 | | - | |
822 | | - | |
823 | 855 | | |
824 | 856 | | |
825 | 857 | | |
| |||
0 commit comments