Repository navigation
Commit 2e242bb
fix(plugin-security)!: a delegated operation runs only in an organization the delegator's membership backs (#25101)
Fixes #25063
Clause-②: no (narrowing) — on a walled posture, an operation of an
on-behalf-of context whose active organization the delegator's
membership does not back is refused `403 PERMISSION_DENIED`, where it
used to run.
Seat call of record: `6312131621` on the card (option P, narrowing
confirmed). The claim `6311248489` was widened in place to the prologue
and the three pins' files. ⛔ Disclosure (#9387 ruling `5323310017`):
this body states the rule and an abstract reach only.
## The rule
ADR-0090 D10 bounds a delegated operation by the delegator's scope. The
operation runs in the live principal's active organization, and
`resolveDelegatorContext` resolves the delegator in that same
organization, assuming the agent and its delegator are in the same
organization. Nothing checked that assumption.
The delegated-principal prologue (`resolveOperationPrincipals`,
`packages/plugins/plugin-security/src/security-plugin.ts`) now checks
it. It sits beside the dangling-delegator refusal and runs before any
gate. It refuses `403 PERMISSION_DENIED`, for every verb, when all three
hold:
- the tenancy posture enforces the organization wall (`isolated` or
`group`);
- the on-behalf-of context carries a live active organization;
- `vetOrganizationClaim(liveOrganization, delegator.accessible_org_ids,
posture)` answers `undefined`. This is the one existing spelling of "a
membership backs this organization", called rather than copied.
No error code is added. The message names the organization and the
delegator, and the developer message carries the `[ADR-0090 D10]` tag.
This PR implements ADR-0090 D10 and does not edit the ADR.
## Reach, abstractly
Reach was shown in process under both walled postures. No public door
builds the refused shape: the `/mcp` OAuth door, the one producer of an
on-behalf-of context, vets the request's organization against the same
membership. Runtime's
`resolve-execution-context.oauth-organization.test.ts`, which drives
that producer into the real `SecurityPlugin`, stays green against the
rebuilt `dist/`.
## Boundary, before and after
- **Before:** under `isolated` or `group`, an on-behalf-of operation in
an organization the delegator is not a member of ran, answered by the
gates the middleware composed for the two principals.
- **After:** that operation is refused `403 PERMISSION_DENIED` at the
prologue, every verb, and nothing runs.
- **Unchanged:**
- the `single` posture, because `vetOrganizationClaim` answers the claim
unchanged there;
- an on-behalf-of context with no active organization, which keeps
ADR-0123 D2's answers;
- a context without `onBehalfOf`;
- a dangling delegator, still refused as before;
- system contexts, which never reach the prologue;
- every context the `/mcp` door builds.
## Pins
- **New:
`packages/plugins/plugin-security/src/delegated-organization-membership.test.ts`**
(25 cases). It runs on real `ObjectQL` (source), a real `SqlDriver` and
a real `SecurityPlugin`, with the delegator rebuilt from stored
`sys_user` / `sys_member` / `sys_user_permission_set` rows.
- **Refusal cells (16):** `group` and `isolated` × a public and a
private object × a single insert, an array insert, a read, and an update
plus a delete. Each asserts `code: 'PERMISSION_DENIED'`, status `403`,
the `[ADR-0090 D10]` developer-message tag and the two subjects the
message names. Each also asserts nothing stored or changed. The
delegator holds the private object's grant in every organization
involved, so the D10 grant intersection never answers for it.
- **Preservation cells (9):** per walled posture, a delegated insert and
read in an organization the delegator belongs to; the same caller
without the delegation link; a delegated context with no active
organization (ADR-0123 D2's refusal, by its tag); and a system context.
Plus the `single` posture.
- The file passes a no-op `hook`, so `kernel:ready` never fires, as
other real-stack files here do. Without it, the plugin starts its
platform bootstrap un-awaited, and a short case destroys the engine
under that bootstrap's reads (knex `Acquire connection error: aborted`
noise; no case failed).
- **Rewritten, because each built a delegated context in an organization
the delegator does not belong to:**
1. `tenant-layer0-verdict-on-operation.test.ts`, "the recorded verdict
is the INTERSECTION — an organization set neither wall names alone". The
delegator's memberships go from `org-2, org-3, org-9` to `org-1, org-3,
org-9`, so they include the live `org-1`. The asserted walls and the
recorded intersection move with them, from `org-2, org-3` to `org-1,
org-3`. That is still a set neither wall names alone. Its control case
("the SAME fixture without the delegation link") takes the same
memberships so it stays the same fixture.
2. Same file, "a delegator with NO membership makes the recorded verdict
`deny`". A delegator with no membership backs no organization, so this
case cannot move to an in-set organization. The caller now carries no
active organization instead: the prologue has nothing to vet, and the
composed wall's `deny` is still the subject.
3. `objects/default-row-scope-declarations.test.ts`, persona "mcp agent
for the platform admin". The fixture gains a `sys_member` row for
`p_admin` in `org_x`, the organization every agent persona's request
runs in. The expected rows are unchanged.
- No other fixture went red. The full `plugin-security` suite is listed
below. The one dependent test that drives an on-behalf-of context into
the real `SecurityPlugin` is runtime's OAuth-organization file, and it
is green.
## Ablation
- **Method:** the check was disabled (`false &&` on its first conjunct)
through `node scripts/ablation-replace.mjs`, in WRAP mode with its own
restore. The subject is imported from source, so there is no `dist/`
leg.
- **Predicted before the run:** the 16 refusal cells go red, and the 9
preservation cells and both rewritten files stay green.
- **Observed:** `Tests 16 failed | 63 passed (79)`, and the 16 are
exactly the refusal cells. Each became an admitted operation, except
where the walls composed for the two principals already hid the row
(`RECORD_NOT_FOUND` on a `group` update).
- **Restore:** the blob after restore equals HEAD (`2f6dfd3fd761`), and
`git diff HEAD` is empty.
## Tests and gates (head `3da7379b5`)
- `plugin-security` full suite: `Test Files 205 passed (205) · Tests
4202 passed | 45 skipped (4247)`, `VERDICT command-exit 0`.
- `plugin-security` `typecheck` (`tsc --noEmit`, the scripts project,
and `check:test-typecheck`): exit 0, with 0 files / 0 errors / 0 pinned
signatures in the test-typecheck debt ledger.
- runtime: `resolve-execution-context.oauth-organization.test.ts` and
`http-dispatcher.test.ts`, against `plugin-security`'s rebuilt `dist/`
(the rule's text was confirmed in `dist/index.mjs`): `Test Files 2
passed · Tests 264 passed`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) derived 69 commands from 5 paths
against merge base `4feccc268`. All 69 exit 0, each exit captured before
any pipe. `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n` first
exited 3 (`PREREQUISITE NOT MET`: no full build). After a full `turbo
run build` both exit 0. `--ran` reads "69 derived, 69 run, 0
NOT-MEASURED, 0 UNRUN" and exits 0.
- **Not run locally:** the dogfood on-behalf-of files, which build agent
contexts with no active organization, so the rule is inert there by
construction. They run on `main`'s battery.
## Acceptance notes
- **The rule lives at the operation prologue.** Paths that resolve a
delegator on their own (explain, the admission probes, a diagnostic) do
not carry the refusal. They are listed for the seat in the dev report,
kept off this PR per the disclosure ruling. No door builds the shape.
- **`resolveDelegatorContext`'s comment** ("the agent and its delegator
are, by construction, in the same org") could point at the prologue
check. `explain-engine.ts` is outside this card's file surface, so it is
left as is.
- **`origin/main` moved after the merge base** (`28c9a2cb6`, spec and
driver-turso). It shares no file with this diff, so it is not merged;
`dispatch-gates` reports that none of those commits touched what this
derivation reads.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 066dabc commit 2e242bb
5 files changed
Lines changed: 379 additions & 5 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 | + | |
| 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 | + | |
| 301 | + | |
| 302 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
440 | 440 | | |
441 | 441 | | |
442 | 442 | | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
443 | 449 | | |
444 | 450 | | |
445 | 451 | | |
| 452 | + | |
446 | 453 | | |
447 | 454 | | |
448 | 455 | | |
| |||
0 commit comments