Repository navigation
fix(plugin-security)!: a delegated operation runs only in an organization the delegator's membership backs - #25101
Conversation
…tion the delegator's membership backs The delegated-principal prologue (`resolveOperationPrincipals`) refuses 403 PERMISSION_DENIED, for every verb, when a walled posture is in force, the on-behalf-of context carries a live active organization, and `vetOrganizationClaim` says the delegator's own membership does not back it. This implements ADR-0090 D10: a delegated operation is bounded by the delegator's scope. `single` and contexts with no active organization keep today's answers; system contexts never reach the prologue. Three existing pins built a delegated context in an organization the delegator does not belong to; they are rewritten to in-set organizations. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
…ort the file already carries Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
…no un-awaited bootstrap outlives a case Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
…only in an organization the delegator belongs to Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bf646ae246020f488000003aef460c46c4b3dd13 && git checkout bf646ae246020f488000003aef460c46c4b3dd13
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8f59304ddc45825f1f4d63f6956e07a75df68c9d 3da7379b584bd9321d8eeed08360567fbb838103 && git checkout -B drift-repro 8f59304ddc45825f1f4d63f6956e07a75df68c9d && git merge --no-ff 3da7379b584bd9321d8eeed08360567fbb838103
node scripts/docs-audit/affected-docs.mjs --json 8f59304ddc45825f1f4d63f6956e07a75df68c9d
|
Contract reviewServed-tier: Rendered at 2026-10-21T10:15Z, on card #25063's body and all five comments, PR #25101's body, file list and net diff from the merge base ① Derived judgments(a) What is refused now that was accepted — confined to the ruled shape: right. The one code change is a branch in (b) The one real producer — judged from the code: no context the (c) The check calls (d) The three rewrites are honest, and each still pins what it pinned. Pin 1 ( (e) The negative pins are ablation-verified: right, on the dev's run (not re-run here). The method is sound: the first conjunct gates the whole refusal, so ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对ACCEPT · #25063 (option P) ·
|
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:
6312131621on the card (option P, narrowing confirmed). The claim6311248489was widened in place to the prologue and the three pins' files. ⛔ Disclosure (#9387 ruling5323310017): 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
resolveDelegatorContextresolves 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 refuses403 PERMISSION_DENIED, for every verb, when all three hold:isolatedorgroup);vetOrganizationClaim(liveOrganization, delegator.accessible_org_ids, posture)answersundefined. 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
/mcpOAuth door, the one producer of an on-behalf-of context, vets the request's organization against the same membership. Runtime'sresolve-execution-context.oauth-organization.test.ts, which drives that producer into the realSecurityPlugin, stays green against the rebuiltdist/.Boundary, before and after
isolatedorgroup, 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.403 PERMISSION_DENIEDat the prologue, every verb, and nothing runs.singleposture, becausevetOrganizationClaimanswers the claim unchanged there;onBehalfOf;/mcpdoor builds.Pins
packages/plugins/plugin-security/src/delegated-organization-membership.test.ts(25 cases). It runs on realObjectQL(source), a realSqlDriverand a realSecurityPlugin, with the delegator rebuilt from storedsys_user/sys_member/sys_user_permission_setrows.groupandisolated× a public and a private object × a single insert, an array insert, a read, and an update plus a delete. Each assertscode: 'PERMISSION_DENIED', status403, 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.singleposture.hook, sokernel:readynever 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 (knexAcquire connection error: abortednoise; no case failed).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 fromorg-2, org-3, org-9toorg-1, org-3, org-9, so they include the liveorg-1. The asserted walls and the recorded intersection move with them, fromorg-2, org-3toorg-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.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'sdenyis still the subject.objects/default-row-scope-declarations.test.ts, persona "mcp agent for the platform admin". The fixture gains asys_memberrow forp_admininorg_x, the organization every agent persona's request runs in. The expected rows are unchanged.plugin-securitysuite is listed below. The one dependent test that drives an on-behalf-of context into the realSecurityPluginis runtime's OAuth-organization file, and it is green.Ablation
false &&on its first conjunct) throughnode scripts/ablation-replace.mjs, in WRAP mode with its own restore. The subject is imported from source, so there is nodist/leg.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_FOUNDon agroupupdate).2f6dfd3fd761), andgit diff HEADis empty.Tests and gates (head
3da7379b5)plugin-securityfull suite:Test Files 205 passed (205) · Tests 4202 passed | 45 skipped (4247),VERDICT command-exit 0.plugin-securitytypecheck(tsc --noEmit, the scripts project, andcheck:test-typecheck): exit 0, with 0 files / 0 errors / 0 pinned signatures in the test-typecheck debt ledger.resolve-execution-context.oauth-organization.test.tsandhttp-dispatcher.test.ts, againstplugin-security's rebuiltdist/(the rule's text was confirmed indist/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 base4feccc268. All 69 exit 0, each exit captured before any pipe.pnpm check:dual-build-cjs-loadsandpnpm check:i18nfirst exited 3 (PREREQUISITE NOT MET: no full build). After a fullturbo run buildboth exit 0.--ranreads "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN" and exits 0.main's battery.Acceptance notes
resolveDelegatorContext's comment ("the agent and its delegator are, by construction, in the same org") could point at the prologue check.explain-engine.tsis outside this card's file surface, so it is left as is.origin/mainmoved after the merge base (28c9a2cb6, spec and driver-turso). It shares no file with this diff, so it is not merged;dispatch-gatesreports that none of those commits touched what this derivation reads.Generated by Claude Code