Repository navigation
Commit 5801a4f
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
File tree
- .changeset
- content/docs/permissions
- 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 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
105 | | - | |
| 105 | + | |
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
| |||
0 commit comments