Skip to content

Commit 5801a4f

Browse files
fix(plugin-security)!: the delegation probes, field projections, explanation and narrowing diagnostic answer the prologue's organization-membership refusal (#25288)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu18uVZPm7Af9b2eJRXsZiPa)_ 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: `6348946648` on the card (card thread, `domain:services` seat 1). Branch `claude/project-thread-a7kyo3` is 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 ruling `5323310017`): this body states the rule and an abstract reach only. ## The rule Under `isolated` or `group`, 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 in `plugin-security` that 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`, beside `resolveDelegatorContext`) asks `vetOrganizationClaim` once 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 or `undefined`. The prologue and `computeDelegatedReadFilter` now 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: | site | answer for a refused context (was) | |:--|:--| | `canReadObject` | `false` (could be `true`) | | `canWriteObject` (insert and update) | `false` (could be `true`) | | `canExport` | `false` (could be `true`) | | `resolveProjectionFieldMask`, behind `getReadableFields`, `getMetadataReadableFields`, `getWritableFields`, `getQueryableFields` | `[]`, as for a dangling delegator (the intersected list) | | the explain engine's delegated path (`explainOnce`) | `allowed: false`; the principal and object_crud layers `denies`, 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. **`ExplainEngineDeps` gains a required `tenancyPosture`**, which the security plugin wires from its own posture. Required for the reason the module gives for `getPartialMaskRules`: 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 with `runtime-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 `/mcp` door) 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 - **Before:** under `isolated` or `group`, for such a context the three probes could answer `true`, the projections the intersected fields, the explanation `allowed: true`, the diagnostic a narrowing, and the per-record write gate asked the sharing service. - **After:** each answers the refusal, as tabled above. - **Unchanged:** the `single` posture; an on-behalf-of context with no active organization; an on-behalf-of context in an organization the delegator belongs to; a context without `onBehalfOf`; a dangling delegator; system contexts. ## Docs - `content/docs/permissions/system-context.mdx`, row 6b: the list of arms `canWriteObject` runs gains, in its fail-closed group, the walled-posture D10 delegator who is not a member of the organization the request runs in (commit `981b7c2f`, 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 (real `ObjectQL` from source, real `SqlDriver`, real `SecurityPlugin`; the delegator rebuilt from stored rows). 24 new cases; the file goes from 42 to 66. The fixture gains `allowExport` on 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. - **Refusal (10):** per walled posture × the three objects (public, private, organization-less): `canReadObject`, `canWriteObject` insert and update and `canExport` are `false`, the four projections `[]`, the explanation `allowed: false` with 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's `canEdit`. - **Preservation (11):** per walled posture, a delegated context in a member organization and the undelegated caller are admitted by every probe on every object; a delegated context with no active organization keeps its read answers; the diagnostic still reports a narrowing in a member organization; the write gate still asks `canEdit` as the agent, then as the delegator; and the `single` posture admits every probe and reports the narrowing. - **Parity (2):** per walled posture, over three contexts × three objects: `canReadObject`, `canExport` and the explanation's `allowed` equal whether the engine's find runs, and `canWriteObject` insert equals whether the engine's insert runs. The table asserts its refused set, so neither side is vacuous. - **Enumeration (1):** per source file in this package, the calls of `delegatedOrganizationRefusal` equal the calls of `resolveDelegatorContext` (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, so `isolated`) whose delegator had no membership in the organization its agent runs in, so with the fix its principal layer read `denies` (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.mjs` WRAP mode (anchor must hit once, blob must move, restore proven: blob equal to HEAD and `git diff HEAD` empty). The subject is imported from source (`./security-plugin.js`), so there is no `dist/` leg. | leg | what is undone (tree only) | file reading | red cells | |:--|:--|:--|:--| | 0, red first | both source files at the base (`cc8d886d`), the new tests kept | 13 failed, 53 passed | the 6 refusal cells, the 2 narrowing-refusal cells, the 2 write-gate cells, the 2 parity cells, the enumeration cell; the 42 existing cells green | | 1 | `describeDelegationNarrowing`'s ask | 3 failed | narrowing refusal × 2, enumeration | | 2 | `delegatedWriteContexts`' ask | 3 failed | write gate × 2, enumeration | | 3 | `resolveProjectionFieldMask`'s ask | 7 failed | refusal × 6, enumeration | | 4 | `canReadObject`'s ask | 9 failed | refusal × 6, parity × 2, enumeration | | 5 | `canWriteObject`'s ask | 9 failed | refusal × 6, parity × 2, enumeration | | 6 | `canExport`'s ask | 9 failed | refusal × 6, parity × 2, enumeration | | 7 | `explainOnce`'s ask | 11 failed | refusal × 6, write gate × 2, parity × 2, enumeration | | 8 | the helper itself answers `undefined` (call counts kept) | 36 failed, 30 passed | every refusal cell of the file, the prologue's and the read scope's 24 included; the enumeration cell stays green, as it should (it counts asks) | Every leg restored: blob equal to HEAD (`security-plugin.ts` `4e9f0dc892a5`, `explain-engine.ts` `aee792270aa0`) and `git diff HEAD` empty. ## Tests and gates Read on head `8bb2d5e0`, branched from `cc8d886d`. Every test run went through `scripts/pm/os-verify-lock.sh`; each exit code was captured before any pipe. - **Dependency closure:** `pnpm exec turbo run build --filter='@objectstack/dogfood^...' --concurrency=3`: `VERDICT command-exit 0`. The eight packages outside it that `check:dual-build-cjs-loads` reads were built too (exit 0). - **The package:** `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. - **The suites outside the package that build an on-behalf-of context through the real `SecurityPlugin`:** `objectql` `engine-readable-ids-nested-composition` (12/12), `service-storage` `attachment-read-leaf.integration` + `attachment-access-hooks` (152/152), `plugin-sharing` `authored-row-write-deferral` (20/20), `plugin-audit` `comment-access-hooks` + `parent-record-read-leaf.integration` (83/83), `runtime` `http-dispatcher` (248/248): each exit 0. - **The derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands; all 70 exit 0 (`check:type-check-debt` re-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. - **Full dogfood battery** (the title carries `!`): `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)`. - **Not run locally, declared to CI:** the type-check lanes and the path-scheduled CI jobs `dispatch-gates` names. **Serial.** #25256 (#25095) landed on `main` after this branch was cut and edits `security-plugin.ts` in hunks disjoint from these (a test merge is clean); CI's run on this PR reads the merge with it. ## Acceptance notes - The helper lives in `explain-engine.ts` beside `resolveDelegatorContext` because the explain engine (a module function, no plugin instance) asks it too; it adds no rule text: `vetOrganizationClaim` stays the one spelling of "a membership backs this organization". - The probes answer silently (no log), as each already answers a dangling delegator silently; the prologue and the read scope keep their `warn`. - A review at `CONTRACT_REVIEW_TIER` is owed before the queue (a narrowing). --- _Generated by [Claude Code](https://claude.ai/code/session_01DPxprKQnzCbhdMTXUGRu31)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb7501b commit 5801a4f

13 files changed

Lines changed: 420 additions & 50 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: the delegation probes, field projections, explanation and narrowing diagnostic answer the refusal the operation prologue makes for a delegated context whose delegator is not a member of the organization it runs in, on a walled posture
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-interface-only packages/plugins/plugin-security/src/explain-engine.ts#ExplainEngineDeps) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. Two things change. The security service's probes answer an in-process caller that builds an on-behalf-of execution context in an organization the delegator does not belong to with the refusal its operations already get; the remedy is to build that context in an organization the delegator is a member of, never a rewrite of a package source or a stored row. And the published runtime interface `ExplainEngineDeps`, which has no metadata surface, gains a required member; the channel that reaches an embedder who builds it is the compiler at their own call site. -->
11+
12+
**BREAKING** (the security service's answers narrow for one context shape, and `ExplainEngineDeps` gains a required member). Under `isolated` or `group`, the delegated-principal prologue refuses every operation of an on-behalf-of context whose active organization the delegator's own membership does not back (ADR-0090 D10, `vetOrganizationClaim`), and the raw-SQL read scope answers that refusal too. The other readers in this package that resolve a delegator on their own did not ask that question, so for the same context they answered an admission the operation refuses. Each now asks it at the prologue's point, through one helper (`delegatedOrganizationRefusal`) that the prologue and the read scope ask as well.
13+
14+
- **FROM:** under `isolated` or `group`, for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of, `canReadObject`, `canWriteObject` and `canExport` could answer `true`; `getReadableFields`, `getMetadataReadableFields`, `getWritableFields` and `getQueryableFields` answered the intersected field list; the access explanation reported the D10 intersection and could answer `allowed: true`; `describeDelegationNarrowing` could report a narrowing; and the explanation's per-record write gate asked the sharing service as the agent and the delegator.
15+
- **TO:** the three probes answer `false`; the four field projections answer `[]`; the explanation answers `allowed: false`, its principal and object_crud layers `denies`, naming the organization the delegator is not a member of; `describeDelegationNarrowing` answers `{ narrowed: false }`, as for a dangling delegator; and the per-record write gate refuses without asking the sharing service.
16+
- **Unchanged:** the `single` posture, an on-behalf-of context with no active organization, an on-behalf-of context in an organization the delegator is a member of, a context without `onBehalfOf`, a dangling delegator, and system contexts. The `/mcp` OAuth door, the one producer of an on-behalf-of context in the platform, vets the request's organization against the same membership, so the contexts it builds keep every answer.
17+
- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand must set its `tenantId` to an organization the delegating user is a member of, or leave it unset.
18+
- **`ExplainEngineDeps`:** an embedder that builds the explain engine's dependencies itself passes the deployment's tenancy posture as `tenancyPosture`, the value the security plugin's enforcement runs under. The compiler names the missing member.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ that silently does not happen.
102102
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#resolveProjectionFieldMask` |
103103
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
104104
| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` |
105-
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
105+
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
106106
| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin.ts#writeWallVerdict` |
107107
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
108108
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |

0 commit comments

Comments
 (0)