Repository navigation
Commit 98c3d00
fix(plugin-security): a platform operator's insert with no active organization gets the ADR-0123 D2 403, not a 500 (#24868)
Fixes #24821
Clause-②: no
## What changed
A platform operator (the `PLATFORM_ADMIN` rung, `admin_full_access`)
whose session has no active organization sent a create on a tenant
object whose posture lets the operator cross the organization wall
(`access.default: 'private'`, ADR-0095 D3). Under `group` and `isolated`
it got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. It now gets the
answer a member gets in the same state: `403 PERMISSION_DENIED`, with a
message that names the missing active organization (ADR-0123 D2/D4).
The fix is in `plugin-security`'s Layer 0 write wall,
`security-plugin.ts` step 3.7, and nowhere else:
- **Why the operator reached the engine.** Crossing makes the operator's
own Layer 0 `null` on such an object. So neither the D2 pre-test
(`organizationWallRefusal`) nor the union-scope insert arm from #24784
reached it. The row then met the engine's organization check
(`resolveSystemInsertOrganization`, ADR-0131 D9) as an organization-less
system write, which refuses with a fixed 500 and keeps the prose off the
wire.
- **The new arm.** An insert from a caller that crosses the wall and has
no `tenantId` is judged on its stored image, in the same
`postHookWriteImageCheck` seam and with the same D2 throw
(`refuseNoActiveOrganization`) as the #24784 arm. The seam runs after
the `beforeInsert` chain and before the engine's organization check. One
row with no organization refuses the whole write.
- **The crossing fact.** `computeLayeredRlsFilter` now also returns
`crossedByPlatformAdmin`. It asks the one Layer 0 ladder
(`computeTenantLayer0Verdict`) a second time with only `isPlatformAdmin`
withheld: the verdict is `none`, and it would not be `none` without the
exemption. No rung of the ladder (posture, tenant object, phantom
anchor, tenancy opt-out) is re-derived. `computeWriteTenantWall` hands
step 3.7 the filter and the fact from one computation.
- **Not touched.** `organizationWallRefusal`,
`callerHasOrganizationScope`, the union-scope arm, the forge guard,
`tenant-layer.ts`, and everything in `packages/objectql`. No new error
code. The engine's 500 still answers system contexts, which are what it
was written for.
## Why this answer (governing text)
- **ADR-0131 D9** decides the outcome: "a write on a tenant-column
object with no organization is derived under `single` … and refused
otherwise, with a message naming what is missing (ADR-0123 D4). ⛔
Nothing defaults to any owner other than the derivable one." No
governing text lands an operator's organization-less insert anywhere. I
searched ADR-0123, ADR-0131 (D5, D9, D12 item 12) and the security
plugin's docblocks. So the insert was already refused; this changes the
answer, not the outcome.
- **The answer.** The condition is the member's: no active organization,
and a new row with nowhere to land. One condition gets one spelling
(ADR-0112 D3), the D2 403. The 500 also failed D9's own "message naming
what is missing" for an HTTP caller, because a 5xx withholds its prose.
The `[#12974]` note in `computeLayeredRlsFilter` already records an
org-less write by the platform owner as "REFUSED 403 … (500 where the
403 refusal belongs)".
- **The fork, reported, not resolved by code.** ADR-0123 D2's scope
paragraph says the refusal fires for a caller "who is not a platform
operator crossing the wall by ADR-0095 D3", and its Consequences list
"Platform operators" under "What does not change". That text predates
ADR-0131 D9, which refuses this write anyway. The runtime is now wider
than that sentence, in the same way #24784's arm made it wider than D2's
"empty membership set" sentence. #24823 is the open card for that
amendment note. This PR changes no ADR text (`docs/adr/**` is Tier H).
## Measured (real stack: ObjectQL + SqlDriver + SecurityPlugin)
Before, at `4be728131` (the pin alone, committed as `743cacb33` and
unchanged since): `Tests 4 failed | 12 passed (16)`. The four failures,
under both `group` and `isolated`, were the single and bulk operator
inserts: `expected 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED' to be
'PERMISSION_DENIED'`. Every control was already green.
After: the operator's insert gets exactly the member's outcome on the
same object (`code`, `status` and message compared whole), and nothing
is stored. These are unchanged, each pinned per posture:
- the operator WITH an active organization is admitted, and the row
lands there;
- a row a `beforeInsert` hook places in an organization is admitted (the
stored image is judged, not the session);
- the operator's update of an existing row is admitted, and the row
keeps its organization;
- the operator's insert into an object that opted out of tenancy is
admitted;
- the operator's read spans every organization;
- a system context with no organization still gets the engine's own `500
ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`.
Also measured at base (scratch file, not committed): a payload that
names `organization_id` is stripped by the engine for a session on a
business object, so the operator's org-less insert naming one was a 500
as well. It is now the same 403.
## Tests
- New `no-active-organization-insert-platform-operator.test.ts`: the
real-stack pin above, one block per posture, with the member's D2 403 as
the control.
- `no-active-organization-write-refusal.test.ts`: its control "a true
PLATFORM operator crosses the wall on a posture-permitting object"
pinned exactly the changed branch, an org-less insert passing the
middleware. In the real stack that insert answered 500. It is re-judged
as follows:
- the crossing is now pinned on what it really admits, the operator's
UPDATE with no active organization;
- the operator's INSERT with no active organization is refused with the
conforming D2 refusal;
- an insert of a row that names its organization lands, and so does an
insert WITH an active organization;
- the business-object sibling now refuses both the insert and the
update, so the posture-scoping of the exemption stays measured.
- Final head `d366c1712` (the merge of `origin/main` `abd5acdaf`), one
locked run: closure build OK; `pnpm --filter
@objectstack/plugin-security test`: `Test Files 196 passed (196)`,
`Tests 4066 passed | 45 skipped (4111)`; `typecheck` exit 0
(`check:test-typecheck: OK … 0 error(s)`). `tsc -p tsconfig.test.json
--listFiles` compiles both edited test files (196 test files in all).
### Ablations (each one committed first, mutated through
`scripts/ablation-replace.mjs` in a script with a restore trap, and
restored to the HEAD blob with `git diff HEAD` empty)
No build or `dist/` leg was needed: the pins import
`./security-plugin.js` relatively, and objectql and driver-sql are
source aliases in this package's vitest config.
1. **The arm off.** Anchor `callerCrossesTenantWall &&` was replaced by
`/* ABLATION-24821 */ false &&` (anchor x1 to x0, marker x0 to x1, blob
`25d6a049` to `9786d9c1`). Result: `Tests 5 failed | 34 passed (39)`, in
the predicted direction (red). The four real-stack refusals failed with
`Received: "ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED"`, and the unit
refusal failed with `the write was not refused at all`. Every control
stayed green.
2. **The crossing discriminator off** (any `none` verdict for an
operator counts as a crossing). The second-ladder comparison was
replaced by `/* ABLATION-24821-B */ true;` (blob `25d6a049` to
`f9b75967`). Result: `Tests 2 failed | 37 passed (39)`, in the predicted
direction: the opted-out-of-tenancy control under both postures
(`expected the write to be admitted`).
## Gates (local, head `d366c1712`)
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 69 commands from the 4
changed paths against merge base `abd5acdaf`. All 69 exit 0, each code
captured before any pipe. `check:dual-build-cjs-loads` and `check:i18n`
first answered exit 3 `PREREQUISITE NOT MET`, which is not a
measurement. After a workspace build (`turbo run build
--filter=!@objectstack/docs`, 76/76) both measured exit 0. `--ran`: `69
derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`. The repo-wide runs (`pnpm
lint`, `test:repo`) are CI's.
## Acceptance notes (observed, not fixed here)
1. **ADR-0123 D2's scope sentence now under-describes the runtime.** It
still says a platform operator crossing the wall is outside the refusal.
Carrier: #24823 (its amendment note can name this arm next to the
union-scope one). See the open question in the report.
2. **A platform operator's cross-organization by-id update on a
posture-permitting object answers 404, while the same caller reads the
row.** Measured in-process at `4be728131` on `qa_vault` (private), row
in `org_b`:
- `group` with no active organization: `404 RECORD_NOT_FOUND`;
- `group` with active `org_a`: `404`;
- `isolated` with active `org_a`: `404`;
- `isolated` with no active organization: `200`.
`findOne` returns the row in all four. This looks like the engine's
by-id write scope being computed over its own facts (no platform-admin
fact), against ADR-0131 D8's one predicate. Reach was not measured at a
public door. Carrier: none.
3. **The engine's organization check has no `isSystem` test.** Any
non-system caller that reaches it without an organization still gets the
system-write 500. After #24784 and this PR the wall covers members and
crossing operators on every object the wall itself recognises as a
tenant object. Populations it may not cover, all unmeasured:
- an object whose declared `tenancy.tenantField` is not
`organization_id`;
- `systemFields.tenant: false` together with an explicit
`organization_id` column;
- a federated object that declares a real remote `organization_id`;
- a composition with no security plugin.
This is the card's second candidate, which is `domain:engine`'s.
Carrier: none.
4. **`canWriteObject`'s create preview.** It is row-less, so for this
operator it still answers `true` (as it did while the write answered
500). The same boundary #24784 recorded.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c2d4072 commit 98c3d00
4 files changed
Lines changed: 478 additions & 24 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 | + | |
| 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 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 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 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
0 commit comments