Repository navigation
fix(plugin-security)!: the delegation probes, field projections, explanation and narrowing diagnostic answer the prologue's organization-membership refusal - #25288
Conversation
…anation and narrowing diagnostic answer the prologue's organization-membership refusal A resolved delegator whose own membership does not back the organization the operation runs in is refused by the delegated-principal prologue on a walled posture (ADR-0090 D10). Every reader that resolves a delegator on its own now asks the same check at the prologue's point, through one helper that asks vetOrganizationClaim with the prologue's inputs: canReadObject, canWriteObject and canExport answer false, the field projections answer no field, the explanation denies, describeDelegationNarrowing reports nothing and the explain path's delegated write contexts are null. The prologue and the raw-SQL read scope ask the same helper, so the rule has one ask in the plugin. ExplainEngineDeps gains a required tenancyPosture. Claude-Session: https://claude.ai/code/session_01DPxprKQnzCbhdMTXUGRu31 Co-authored-by: Claude <noreply@anthropic.com>
…'s organization-membership refusal Refusal, preservation and parity cells for canReadObject, canWriteObject, canExport, the four field projections, the explanation, the narrowing diagnostic and the explanation's per-record write gate, plus an enumeration pin: every source file that resolves a delegator asks the refusal as often. Claude-Session: https://claude.ai/code/session_01DPxprKQnzCbhdMTXUGRu31 Co-authored-by: Claude <noreply@anthropic.com>
…r the prologue's organization-membership refusal Clause-②: no (narrowing). ExplainEngineDeps gains a required tenancyPosture (runtime-interface-only ADR-0087 disposition). Claude-Session: https://claude.ai/code/session_01DPxprKQnzCbhdMTXUGRu31 Co-authored-by: Claude <noreply@anthropic.com>
…member of the organization its agent runs in The control cell "a delegator that resolves leaves the principal layer neutral" runs on a walled stack (org scoping → isolated) whose delegator had no membership row, so the explanation now answers the prologue's D10 organization-membership refusal there. Seed the membership, so the cell measures what it names. Claude-Session: https://claude.ai/code/session_01DPxprKQnzCbhdMTXUGRu31 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 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 1329cb5f6b01ba56dd83313a9ed6687b2d022d0e && git checkout 1329cb5f6b01ba56dd83313a9ed6687b2d022d0e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c9be979dd1580f2a92db62a0a5104bf2abae1129 981b7c2f04124ccf1308aa6403647f36e69354a9 && git checkout -B drift-repro c9be979dd1580f2a92db62a0a5104bf2abae1129 && git merge --no-ff 981b7c2f04124ccf1308aa6403647f36e69354a9
node scripts/docs-audit/affected-docs.mjs --json c9be979dd1580f2a92db62a0a5104bf2abae1129
|
…ization-membership arm canWriteObject now runs Row 6b of the system-context bypass table names every arm the elevated write-preview question runs; canWriteObject now also refuses, on a walled posture, a delegator who is not a member of the organization the request runs in. Claude-Session: https://claude.ai/code/session_01DPxprKQnzCbhdMTXUGRu31 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #25107 (body, claim ① Derived judgmentsThe one helper — right. The prologue (
Every site the card names asks at the prologue's point — right, nothing left out, none wider than the prologue. Read at the head, each ask sits immediately after a
No site answers wider than the prologue: each ask takes the same context the operation would carry and the same resolved delegator, and sits behind guards at least as strict as the prologue's. No reader resolving a delegator is left out: repo-wide on current What stays unchanged — right. The non-walled posture (the claim stands in core); a context with no active organization (the helper answers nothing before asking core, ADR-0123 D2 keeps those answers); a member organization; a context without the delegation link (every ask is inside an on-behalf-of branch); a dangling delegator (the
Pins — real negative pins, ablation reported per site. The 24 new cells in the rule's file run the real engine, driver and plugin with the delegator rebuilt from stored rows. Refusal cells assert the refused value per probe, per walled posture, per object class, and assert the explanation's two layer verdicts and the cause they name; the write-gate cells assert the sharing service's edit gate is never called; the preservation cells assert every admitted class keeps every admission (member organization, undelegated caller, no active organization, the non-walled posture, the narrowing still reported, the gate still asked twice in order); the parity cells equate each probe with the engine operation it describes over a context-by-object table and assert the refused set is exactly the one row class, so neither side is vacuous; the enumeration cell counts asks against resolutions per source file and pins that both files are counted. The fixture's export grant on every set is what keeps the export refusal attributable to this rule alone, as the card's D10-intersection caution requires. Red-first at the base with the new tests (13 red, exactly the non-preservation cells) and one WRAP-mode ablation leg per site (7 legs, each reddening its own cells plus the enumeration cell, plus the helper leg reddening every refusal cell including the pre-existing 24) are reported with restore proofs. Read-only here: I accept those readings as the dev's and note the check-runs on this head as the gate verdicts. The moved control — not a relaxation. Lane floor. Every accept-set change in this diff is allow-to-deny, aligned with the operation prologue; no permission or security boundary is relaxed anywhere in the diff or the cited code. ② Semver levelChangeset Right, on the house rules. AGENTS.md Post-Task step 3: the level follows the metadata-protocol kind alone; ③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT · 2026-10-22T21:20Z
⛔ Disclosure (#9387): abstract. The PR body, the report
Generated by Claude Code |
Requested by Sales · project thread
Fixes #25107
Clause-②: no (narrowing) — on a walled posture, the security service's admission probes, field projections, access explanation and narrowing diagnostic answer the operation prologue's refusal for an on-behalf-of context whose live active organization the delegator's membership does not back, where they used to answer an admission.
Metadata-protocol: none
Claim of record:
6348946648on the card (card thread,domain:servicesseat 1). Branchclaude/project-thread-a7kyo3is the harness-pinned branch for this session; it names no issue, so the Duplicate Fix Guard's branch-name hint will not match, and the claim is the identity record. ⛔ Disclosure (#9387 ruling5323310017): this body states the rule and an abstract reach only.The rule
Under
isolatedorgroup, the delegated-principal prologue (resolveOperationPrincipals) refuses every operation of an on-behalf-of context whose live active organization the delegator's own membership does not back (ADR-0090 D10, landed with #25063), and the raw-SQL read scope answers that refusal (#25106, PR #25150). Every other reader inplugin-securitythat resolves a delegator on its own skipped the check, so for such a context it answered an admission the operation refuses. The card names them; this PR makes each one ask.One ask.
delegatedOrganizationRefusal(explain-engine.ts, besideresolveDelegatorContext) asksvetOrganizationClaimonce with the prologue's inputs: the live organization (context.tenantId), the delegator's own memberships (accessible_org_ids, never inherited), the posture. It answers the refused organization orundefined. The prologue andcomputeDelegatedReadFilternow ask it instead of spelling the inputs themselves (byte-identical behaviour: their 42 existing cells stay green unchanged), and every other site asks it at the prologue's point, after a resolved delegator and before the delegator's sets resolve:canReadObjectfalse(could betrue)canWriteObject(insert and update)false(could betrue)canExportfalse(could betrue)resolveProjectionFieldMask, behindgetReadableFields,getMetadataReadableFields,getWritableFields,getQueryableFields[], as for a dangling delegator (the intersected list)explainOnce)allowed: false; the principal and object_crud layersdenies, naming the organization (the D10 intersection)describeDelegationNarrowing{ narrowed: false }, as for a dangling delegator (a narrowing)delegatedWriteContexts(the explanation's per-record write gate and authored-row probe)null: the gate refuses without asking the sharing service (asked as agent and delegator)resolveDelegatorContext's docblock no longer says the agent and its delegator are "by construction, in the same org": that is the precondition the helper vets, and the docblock points at it.ExplainEngineDepsgains a requiredtenancyPosture, which the security plugin wires from its own posture. Required for the reason the module gives forgetPartialMaskRules: an engine wired without it would explain such a context as admitted, the misreport this card closes, and a required member makes the omission a compile error. The seven test files that build the deps by hand pass'single'. The changeset names this withruntime-interface-only(the interface has no metadata surface; the compiler is the channel that reaches an embedder).Reach, abstractly
In process only, as the card says: the one producer of an on-behalf-of context (the
/mcpdoor) never builds the shape, and none of these readers serves rows (their consumers run the operation through the engine, which refuses).Boundary, before and after
isolatedorgroup, for such a context the three probes could answertrue, the projections the intersected fields, the explanationallowed: true, the diagnostic a narrowing, and the per-record write gate asked the sharing service.singleposture; an on-behalf-of context with no active organization; an on-behalf-of context in an organization the delegator belongs to; a context withoutonBehalfOf; a dangling delegator; system contexts.Docs
content/docs/permissions/system-context.mdx, row 6b: the list of armscanWriteObjectruns gains, in its fail-closed group, the walled-posture D10 delegator who is not a member of the organization the request runs in (commit981b7c2f, one line, flagged by the docs drift check).Pins
All in
packages/plugins/plugin-security/src/delegated-organization-membership.test.ts, the rule's file (realObjectQLfrom source, realSqlDriver, realSecurityPlugin; the delegator rebuilt from stored rows). 24 new cases; the file goes from 42 to 66. The fixture gainsallowExporton its three grants (so the export probe's admitted cells are admitted by a grant) and one object only the narrowing cells read, with a delegator grant and an agent ceiling for it.canReadObject,canWriteObjectinsert and update andcanExportarefalse, the four projections[], the explanationallowed: falsewith both layers denying and naming the organization; per walled posture, the narrowing diagnostic answers{ narrowed: false }, and the explanation's per-record write gate never calls the sharing service'scanEdit.canEditas the agent, then as the delegator; and thesingleposture admits every probe and reports the narrowing.canReadObject,canExportand the explanation'sallowedequal whether the engine's find runs, andcanWriteObjectinsert equals whether the engine's insert runs. The table asserts its refused set, so neither side is vacuous.delegatedOrganizationRefusalequal the calls ofresolveDelegatorContext(definitions excluded), and the two files that resolve a delegator are both counted: a tenth reader that resolves one without asking goes red.One existing pin moves, because this change makes its fixture the refused shape:
explain-dependency-fault.test.ts's control "a delegator that resolves leaves the principal layer neutral" runs on a walled stack (org scoping, soisolated) whose delegator had no membership in the organization its agent runs in, so with the fix its principal layer readdenies(the package suite caught it). Its fixture seeds that membership, so the cell measures what it names; its sibling (the delegator's grants unreadable, explain fails closed) is untouched.One-time proofs (not left as tests), each in
scripts/ablation-replace.mjsWRAP mode (anchor must hit once, blob must move, restore proven: blob equal to HEAD andgit diff HEADempty). The subject is imported from source (./security-plugin.js), so there is nodist/leg.cc8d886d), the new tests keptdescribeDelegationNarrowing's askdelegatedWriteContexts' askresolveProjectionFieldMask's askcanReadObject's askcanWriteObject's askcanExport's askexplainOnce's askundefined(call counts kept)Every leg restored: blob equal to HEAD (
security-plugin.ts4e9f0dc892a5,explain-engine.tsaee792270aa0) andgit diff HEADempty.Tests and gates
Read on head
8bb2d5e0, branched fromcc8d886d. Every test run went throughscripts/pm/os-verify-lock.sh; each exit code was captured before any pipe.pnpm exec turbo run build --filter='@objectstack/dogfood^...' --concurrency=3:VERDICT command-exit 0. The eight packages outside it thatcheck:dual-build-cjs-loadsreads were built too (exit 0).pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2:Test Files 209 passed (209),Tests 4320 passed | 45 skipped (4365).pnpm --filter @objectstack/plugin-security run typecheck: exit 0.SecurityPlugin:objectqlengine-readable-ids-nested-composition(12/12),service-storageattachment-read-leaf.integration+attachment-access-hooks(152/152),plugin-sharingauthored-row-write-deferral(20/20),plugin-auditcomment-access-hooks+parent-record-read-leaf.integration(83/83),runtimehttp-dispatcher(248/248): each exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 70 commands; all 70 exit 0 (check:type-check-debtre-run under the lock after its first call timed out queueing behind the battery, which is a prerequisite reading, not a verdict).--ran:70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. The three roster gates whose roster sits under a directory this diff touches (check-changeset-fixed,check:error-code-casing,check:filter-alias-parity) were run as well: exit 0.!):pnpm --filter @objectstack/dogfood test, no shard, no path:VERDICT command-exit 0,Test Files 289 passed | 3 skipped (292),Tests 2481 passed | 18 skipped (2499).dispatch-gatesnames.Serial. #25256 (#25095) landed on
mainafter this branch was cut and editssecurity-plugin.tsin hunks disjoint from these (a test merge is clean); CI's run on this PR reads the merge with it.Acceptance notes
explain-engine.tsbesideresolveDelegatorContextbecause the explain engine (a module function, no plugin instance) asks it too; it adds no rule text:vetOrganizationClaimstays the one spelling of "a membership backs this organization".warn.CONTRACT_REVIEW_TIERis owed before the queue (a narrowing).Generated by Claude Code