Repository navigation
Commit f2626c7
fix(plugin-security)!: the Layer 0 tenant write wall refuses an update that empties a row's organization (#22317)
Fixes #22278
Clause-②: no (narrowing)
## What changes
`plugin-security`'s Layer 0 tenant write wall (`security-plugin.ts`,
step 3.7, the tenant post-image check) judged only an organization a
write *names*. A non-system update that *emptied* the row's organization
was never judged, on either half of the step: the payload as sent, and
the row the engine is about to store (the #20013 seam, by id and by
predicate).
On UPDATE the step now holds its stated invariant whole: where the
tenant wall applies, the row's organization after a non-system update is
one the wall admits. An emptied organization is refused on both halves
with the wall's `403 PERMISSION_DENIED` (ADR-0112 envelope), and nothing
is stored. The step's comment states the invariant.
- INSERT keeps today's rule: an absent or empty organization is the
stamp's to fill.
- System writes, the platform-administrator posture exemption on
posture-permitting objects, and the `single` posture are unchanged.
- An update that does not touch the column, or keeps the row's own
organization, is unchanged.
Scope as ruled (triage `6059271770`, the seat's ruling B): one region of
`security-plugin.ts`, one test file, one changeset. No `packages/spec`,
no change to how `@objectstack/core` reads an organization-less grant,
no change to `grant-holder-membership-refusal.ts`, nothing that widens
access.
## Tests: `tenant-wall-organization-removal.test.ts`
Both SQL driver families (driver-sql on better-sqlite3,
driver-sqlite-wasm), `isolated` posture unless a cell says otherwise.
Refusals assert `code` and `status`, that the refusal names the object,
and that the stored rows are unchanged.
- Negative pins (7 per driver): an emptied organization on both grant
tables, by id and by predicate; the platform administrator on the grant
tables (their posture grants no exemption); an ordinary tenant object on
both update paths; the stored-row half (a value the `beforeUpdate` chain
wrote) on both update paths.
- Controls (10 per driver): an untouched-column update on both grant
tables and on an ordinary tenant object; a system write; an explicit
foreign organization, still refused; the platform-administrator
exemption on a posture-permitting object, stored-row half included; the
`single` posture; an INSERT keeps its stamp.
| run | tree | result |
|---|---|---|
| before the fix (merged base, corrected controls) | `d3ec5074fb` | 12
negatives red, 20 controls green |
| with the fix | `edafba04e9` | 34 of 34 green |
### The two controls that were red on base: wrong expectation, diagnosed
"A platform administrator on a posture-permitting object is exempt" and
"the `single` posture is unchanged" asserted that a caller-*sent* empty
organization is stored on an ordinary tenant object. It is not, wall or
no wall. There `organization_id` is the registry's injected `readonly`
column, and the engine's static-readonly strip (`stripReadonlyFields`)
drops a non-system caller's value before the statement; a value the hook
chain wrote survives the strip. The grant tables declare the column
themselves, writable, so a sent value is stored as sent.
Measured in the corrected `single`-posture control, on both drivers: the
injected column reads `readonly: true` and the grant table's does not; a
sent empty value is stored on the grant table and dropped on the
ordinary object; a hook-written one is stored. Both controls now read
each store as the engine writes it (the platform-administrator control
drives the stored-row half through the hook), and are green before and
after the fix.
### Ablation
One-shot, from the committed fix at `edafba04e9`, through
`scripts/ablation-replace.mjs` (anchor count 1 to 0 on disk each time;
every restore proven: blob equal to HEAD's, `git diff HEAD` empty). The
subject is imported from source (`./security-plugin.js`), so no dist leg
applies.
| mutation | red | reading |
|---|---|---|
| A1: the payload-half refusal disabled | 2 (ordinary tenant object,
both drivers) | on the grant tables the stored-row half still refuses:
the halves overlap there |
| A2: the stored-row-half refusal disabled | 2 (the hook-written cell,
both drivers) | |
| A3: the emptied-organization predicate disabled (both halves) | 14
(all 7 negatives, both drivers); 20 controls green | |
A first A3 attempt was refused by the tool before anything ran (its
anchor sat inside the replacement text); it measured nothing.
## Blast radius (at `edafba04e9`)
- `@objectstack/plugin-security`, full suite: 183 files, 3865 passed, 45
skipped.
- `@objectstack/organizations`, full suite: 11 files, 151 passed.
- `@objectstack/dogfood`, the 32 files that write `sys_user_position` /
`sys_user_permission_set` rows, against a dist rebuilt at `edafba04e9`:
32 files, 279 passed, 1 skipped.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, test
layer included (`check:test-typecheck`).
- Non-system callers that empty an organization on update: none found in
`packages/**` source. Every non-test null `organization_id` literal
there is a read predicate or a system-context insert.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 65 commands at
`edafba04e9`. All 65 ran on that tree with their exit codes recorded,
and `--ran` answered: "65 derived famil(ies) accounted for — 65 run, 0
NOT-MEASURED". `check:dual-build-cjs-loads` first answered PREREQUISITE
NOT MET (exit 3: eight packages had no dist); after building those it
answered exit 0, and that is the recorded run. Selected verdict lines:
- `check-adr-0087-registration: 1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition.`
- `This diff introduces no major bump.`
- `check-nul-bytes: OK (scanned 10272 text file(s) ... no raw ASCII
control bytes).`
- `check-type-check-coverage: OK`, `check-test-source-alias OK`,
`check:cross-package-test-inputs OK`.
Lint (the repo-wide run is CI's): eslint on the two touched `.ts` files,
`--no-inline-config --format json`, counted 2 files, 0 errors, 0
warnings. The repo's one `eslint.config.mjs` enables no type-aware
linting (its own comment says so: no `parserOptions.project`, no typed
rules), so this diff cannot move a verdict on any untouched file.
## Acceptance notes
- `main` moved three commits after this branch merged `fe72ad7828` (a
spec type-only change, `cli`, `platform-objects`). None touches
`plugin-security`, `objectql`, `core` or `organizations`, so the branch
was not merged again; CI and the queue validate the merge.
- The Setup UI (`objectui`) is not checked out in this container, so
whether a form there can send an emptied organization on these tables is
NOT MEASURED. If one can, it now receives the 403 above.
- Changeset: one `@objectstack/plugin-security` `minor`, with the
breaking banner and the ADR-0087 disposition `not-required
(no-migration-prescription)`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 59d993c commit f2626c7
3 files changed
Lines changed: 472 additions & 6 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3960 | 3960 | | |
3961 | 3961 | | |
3962 | 3962 | | |
3963 | | - | |
3964 | | - | |
3965 | | - | |
3966 | | - | |
3967 | | - | |
3968 | | - | |
| 3963 | + | |
| 3964 | + | |
| 3965 | + | |
| 3966 | + | |
| 3967 | + | |
| 3968 | + | |
| 3969 | + | |
| 3970 | + | |
| 3971 | + | |
| 3972 | + | |
| 3973 | + | |
| 3974 | + | |
| 3975 | + | |
| 3976 | + | |
| 3977 | + | |
| 3978 | + | |
| 3979 | + | |
| 3980 | + | |
| 3981 | + | |
| 3982 | + | |
| 3983 | + | |
| 3984 | + | |
| 3985 | + | |
3969 | 3986 | | |
3970 | 3987 | | |
3971 | 3988 | | |
| |||
4079 | 4096 | | |
4080 | 4097 | | |
4081 | 4098 | | |
| 4099 | + | |
| 4100 | + | |
| 4101 | + | |
| 4102 | + | |
| 4103 | + | |
| 4104 | + | |
| 4105 | + | |
| 4106 | + | |
| 4107 | + | |
4082 | 4108 | | |
4083 | 4109 | | |
4084 | 4110 | | |
| |||
4114 | 4140 | | |
4115 | 4141 | | |
4116 | 4142 | | |
| 4143 | + | |
| 4144 | + | |
| 4145 | + | |
| 4146 | + | |
| 4147 | + | |
| 4148 | + | |
| 4149 | + | |
| 4150 | + | |
| 4151 | + | |
| 4152 | + | |
| 4153 | + | |
| 4154 | + | |
| 4155 | + | |
| 4156 | + | |
| 4157 | + | |
| 4158 | + | |
| 4159 | + | |
| 4160 | + | |
4117 | 4161 | | |
4118 | 4162 | | |
4119 | 4163 | | |
| 4164 | + | |
| 4165 | + | |
4120 | 4166 | | |
4121 | 4167 | | |
4122 | 4168 | | |
| |||
4158 | 4204 | | |
4159 | 4205 | | |
4160 | 4206 | | |
| 4207 | + | |
| 4208 | + | |
| 4209 | + | |
| 4210 | + | |
| 4211 | + | |
| 4212 | + | |
| 4213 | + | |
4161 | 4214 | | |
4162 | 4215 | | |
4163 | 4216 | | |
| |||
4166 | 4219 | | |
4167 | 4220 | | |
4168 | 4221 | | |
| 4222 | + | |
4169 | 4223 | | |
4170 | 4224 | | |
4171 | 4225 | | |
| |||
0 commit comments