Repository navigation
fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth - #20000
Conversation
… pre-fix) Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…ad depth The record-grained read verdict asked plugin-sharing's buildReadFilter with the bare context, while the find path stamps __readScope (step 2.6) first, so an org or unit reader was reported record.visible: false on rows its find returns. Route the non-delegated binding through resolveSharingReadFilter, the in-class mirror of step 2.6, with the caller's own key cleared first. The on-behalf-of context keeps its previous input. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
… double Generated by check-engine-double-contract --write (0 seams added or lost). Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 15 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 51a2357ff0224c1eb86b02726c723225abe0f9d4 && git checkout 51a2357ff0224c1eb86b02726c723225abe0f9d4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b 8644de0cbe7fe1a711f97ab363ed41426b2010a3 && git checkout -B drift-repro e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b && git merge --no-ff 8644de0cbe7fe1a711f97ab363ed41426b2010a3
node scripts/docs-audit/affected-docs.mjs --json e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b
|
…s with enforcement throws (objectstack-ai#20030) Fixes objectstack-ai#20002 Clause-②: no The `security/explain` route promises that it runs 「the same code paths the enforcement middleware runs」. Enforcement does not catch a failure in those calls, so the request fails. The explain engine caught the same failure and turned it into a value that its record matcher reads as an answer. So a request that fails was reported as one that succeeds. This is the same defect class as objectstack-ai#19963 and objectstack-ai#19986 (PR objectstack-ai#19984, PR objectstack-ai#20000): explain and enforcement give two different answers. Enforcement is not touched. This PR changes the report only. ## Measurement first (on `origin/main` `fc6ddb87a4`, before any fix) The stack is the real `SecurityPlugin` + `SharingService` + both middlewares over one in-memory engine, in the shape of PR objectstack-ai#20000's test file. For each caught fall-back in `explain-engine.ts`, the dependency was made to fail, and the same request was run through both middlewares. Commit `02067686d4` adds the test file that pins the fix. On the unfixed engine its 10 fault cells are red and its 6 controls are green. The commit message carries this table. | Site | Caught into | What explain reported when the dependency failed | What enforcement did | Verdict | |---|---|---|---|---| | `:848` `fetchRecord` | `null` | Record not found: `visible: false`, no `decidedBy` | The find throws on the same store fault | Fails toward not visible. Left as is. The plugin's own binding already catches to `null`, so in this wiring this catch is never reached. | | `:858` `computeLayeredRlsFilter` | `{ layer0: null, layer1: null }` | `allowed: false` (the object-level catch denies), but `record.visible: true` for a shared row, an owned row and an org-depth row | The find throws the injected error | **Fails OPEN** at row level, and contradicts `allowed`. Changed. | | `:941` `listRecordShares` | `[]` | Verdict unchanged, because the read filter decides it. `rules[]` is empty | Unaffected: enforcement never calls `listShares` | Only the attribution changes: it can only drop an `admits` rule. Left as is. | | `:958` `sharingReadFilter` (the card) | `null` | Share store down, own-depth reader: `visible: true` on the unshared, shared and owned rows. Sharing is `admitted` with `rowFilter: null` | The find throws the store error | **Fails OPEN.** Changed. | | `:966` write gate (`canEdit` / `canDelete`) | `undefined` ("no gate wired") | Update on an owned row: `visible: true` (private and `public_read`). On a row with only a READ share, sharing reported `admitted` | The by-id PATCH throws the injected error | **Fails OPEN.** Changed. | | `:1128` `resolveSets` | `[]` | `allowed: false`, `decidedBy: object_crud` | 403 `PERMISSION_DENIED` | Fails toward deny. Left as is. | | `:1140` `resolveDelegatorContext` (not on the dispatch list, found by the same sweep) | `{ kind: 'none' }` ("no delegation") | Delegator's grant store down: `allowed: true`, `principal` `neutral`. The D10 intersection was dropped (a healthy delegator gives `allowed: false`) | 503 `SERVICE_UNAVAILABLE` | **Fails OPEN** (object-level `allowed`). Changed. | | `:1147` delegator `resolveSets` | `[]` | `allowed: false`, `decidedBy: object_crud` | The find throws | Fails toward deny. Left as is. | Two storage-level controls from the same probe: - With the share store down, an `org`-depth reader's read filter answers `null` before it reads a share, so both sides admit. - The per-record gate catches its own store faults (`writeGateFailClosed`), so an update during the same outage already got the same answer from both sides. ## What changed In `packages/plugins/plugin-security/src/explain-engine.ts`: - **The helper.** A `settle` helper keeps a failure apart from every value the call can return: it returns a `DEPENDENCY_FAULT` sentinel instead. It is used only at the four fail-open sites. - **Sharing read filter and write gate.** The sharing layer's record outcome is `not_evaluated`, with no `rowFilter` and no `matchesRecord`. Its `detail` says the layer could not be evaluated and that the request fails on the same call. `record.visible` is `false`, with `decidedBy: 'sharing'`. - The call that is checked is the one the operation's verdict depends on: the read filter for a read, and the per-record gate for a write. - It is checked before the OWD, because the sharing middleware calls it whatever the OWD is. - **Layered RLS composition.** The `tenant_isolation` and `rls` record attributions are `not_evaluated`, with a detail that names the failure. `record.visible` is `false`, with `decidedBy: 'rls'`. This matches the object-level `rls` layer, which already reports the same failure as a denial. - **Delegator resolution.** A new `delegatorUnresolved` flag fails closed like a missing delegator. `principal` and `object_crud` deny with their own wording, and `allowed` is `false`. - **Order in the record verdict.** Unchanged first: capability, CRUD, missing record, tenant exclusion, RLS exclusion. Then an RLS-composition failure, then a sharing failure. This follows the pipeline, where the RLS composition runs before the sharing middleware. **Outcome vocabulary.** `ExplainRecordAttributionSchema.outcome` in `packages/spec/src/security/explain.zod.ts` is `admitted | excluded | not_evaluated`. The engine already uses `not_evaluated` for "Tenant layer split is unavailable on this engine build" and for a record that is not found. So `not_evaluated` plus a detail that names the failure says "could not be evaluated". The response has no new value and no new key, and `packages/spec` is not edited. **Not changed:** - `security-plugin.ts`. PR objectstack-ai#20012 has since landed there, and this branch merges it. - `default-permission-sets.ts`. - `packages/spec`. - Enforcement. ## Tests The new file `packages/plugins/plugin-security/src/explain-dependency-fault.test.ts` uses the real `SecurityPlugin` + `SharingService` + both middlewares over one in-memory engine. Each fault cell asserts both sides: - **Explain:** `record.visible === false` with the named `decidedBy`, and the layer is `not_evaluated` with no `rowFilter` and no `matchesRecord`. - **Enforcement:** the same request through both real middlewares fails **with the injected fault itself**, compared by identity. For the delegator, the cell asserts the ADR-0112 envelope: `SERVICE_UNAVAILABLE` / 503. The fault cells: - Share store down × unshared / shared / owned row (read), and `buildReadFilter` failing on a `public_read` object. - `canEdit` failing × owned row (private and `public_read`). - `computeLayeredRlsFilter` failing × read-shared / owned / org-depth row. These cells also assert `allowed: false`. - Delegator's grant store down (`sys_user_position`): `allowed: false`, and `principal` and `object_crud` deny. The controls: - A healthy shared row: admitted, `decidedBy: sharing`. - A healthy unshared row: excluded. - An org-depth reader whose filter answers `null` while the share store is down: still admitted, because only a failure is a fault. - A healthy update gate, a healthy tenant wall, and a healthy delegator (`principal` `neutral`). **Ablation of every negative pin, on HEAD `e8e677786a`.** Each leg put one swallowing catch back with `scripts/ablation-replace.mjs`. The anchor went from 1 hit to 0, and the file's blob changed on disk. The test file then went red on exactly that site's cells. Each leg restored the file: blob `fca6431074ac` equals HEAD, and `git diff HEAD` is empty. | Leg | Catch put back | Red | Green | |---|---|---|---| | A1 | `:958` `.catch(() => null)` | 4, the read-filter cells (`record.visible`: `expected true to be false`) | 12 | | A2 | `:966` `.catch(() => undefined)` | 2, the write-gate cells | 14 | | A3 | `:858` `.catch(() => ({ layer0: null, layer1: null }))` | 3, the layered-RLS cells | 13 | | A4 | `:1140` `.catch(() => ({ kind: 'none' }))` | 1 (`allowed`: `expected true to be false`) | 15 | The test reaches the code under test through relative `src` imports (`./security-plugin.js` → `./explain-engine.js`), so no `dist` sits between the mutation and the run. **Runs on HEAD `e8e677786a`**, after merging `origin/main` at `44639665ee`: - `@objectstack/plugin-security`: `typecheck` exits 0, and `tsconfig.test.json` lists the new file. The full suite passes: **130 files, 2541 tests**. - Consumers of the explain route, found with `grep -rln "security/explain\|explainAccess" packages --include=*.test.ts`: - rest: `security-routes`, `security-explain-envelope`, `rest-write-response-internal-fields.tripwire`: 45/45. - plugin-sharing: `sharing-service`: 131/131. - client: `client.test`: 217/217. - dogfood: `api-key-owner-revoke`, `owd-public-read-write-write-floor`, `showcase-d7-default-profile`: 23/23. - spec: `type-alias-convention.pin`, `explain-zero-rows-sentinels.pin`: 11/11. - objectql: `engine-middleware-operation-vocabulary`: 5/5. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on this HEAD derives 71 commands, and all 71 ran. - `check-changeset-fixed`, `check:error-code-casing` and `check:filter-alias-parity` also ran, because their rosters sit under paths this diff touches. - Reconciliation, with every line recorded with its exit code: `Run reconciliation — 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:type-check-debt` first answered PREREQUISITE NOT MET, because the plugin-security `dist` was older than its sources. After a rebuild it answered `OK — 4 ledger entr(ies) re-measured … none above its recorded number`. - **Lint, narrowed to the changed files.** `eslint --no-inline-config --format json` on the two changed TS files: 2 files, 0 errors, 0 warnings. - The changeset and the JSON ledger are outside eslint's configured file set ("File ignored because no matching configuration was supplied"). - The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot change the lint result of any other file. ## Also in the diff - `scripts/engine-double-contract.pinned.json`: one row added (`explain-dependency-fault.test.ts`, `findOne`), regenerated with `--write`. `check:engine-double-contract` (RETAINED) needs the coverage ledger to record every new pinned double. No row was lost. - `.changeset/20002-explain-sharing-fault.md`: `@objectstack/plugin-security: patch`, `Clause-②: no`. The report now matches what enforcement already does, and no accept set moves. ## Acceptance notes - **No log line is added at the four sites.** The suggested route said to log the failure the way enforcement does, but I did not, for two reasons: - The explain engine has no logger dependency, and wiring one would mean editing `security-plugin.ts`, which is out of scope here. - AGENTS.md "Degradation log levels" says that a failure delivered to the caller is not a degradation. The report delivers it: `not_evaluated`, a detail, and a fail-closed verdict. The real request's failure shows up on the request that fails. - **The detail names the failure, not the thrown message.** Explaining your own access needs no capability, so a raw store error message would reach an ordinary caller. - **`:848` and `:941` word a failure imprecisely.** They still say "Record not found" or "0 share(s) attached". Their verdicts fail toward not visible, so they are left as is. - **The `tenant_isolation` layer-level `verdict` stays `not_applicable` under a layered-RLS failure.** That code is unchanged, and the verdict enum has no "unknown". The record attribution carries the failure. - **A spec describe text does not mention failures.** `ExplainRecordAttributionSchema.outcome`'s describe text reads "not_evaluated (skipped/not row-scoped)". The engine already uses the value for "unavailable", so this is a docs detail in `packages/spec`, and it is not edited here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19986
Clause-②: no
What changes
POST /api/v1/security/explainwith{ object, operation: 'read', recordId }now asks plugin-sharing's read filter with the same read depth the find path hands it.decision.record.visibleforreadequals whether the same caller'sfindreturns the row. Enforcement admits and refuses exactly what it did before. Only the explain report changes.Landing:
packages/plugins/plugin-security/src/security-plugin.ts(theexplainAccessForCallerdependency wiring only), a new test file, the changeset, and one generated row inscripts/engine-double-contract.pinned.json(see "Surface note"). The step-3.6 post-image block and the seam declaration are untouched. The producer is the explain wiring, as the card says; no other package is involved.The two sides, quoted on
origin/mainccccdcc35ebefore the changesecurity-plugin.ts:4426):sharingReadFilter: (o: string, c: any) => sharing.buildReadFilter(o, c). The bare context, no__readScope.security-plugin.ts:2375,:2403): forfind/findOne/count/aggregateon a non-delegated context,sc.__readScopeis set tothis.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }).permissionSetscomes fromresolvePermissionSetsForContextandsecMetafromgetObjectSecurityMeta. plugin-sharing'sbuildReadFilterthen reads it:orggives no owner filter (null), and a unit depth gives the unit's owners.resolveSharingReadFilter(security-plugin.ts:4490after this change; it is the analytics path's seam) computes the depth from the same two inputs and the same evaluator call, stamps it, and callsbuildReadFilter. Its docblock names the situation explain is in: no middleware runs on this path, so the depth is computed there. The change reuses it. Nothing new is computed.Reproduced before the change
The new test file was committed first on the unfixed tree (
07f447e0c0, baseccccdcc35e). It wires the realSecurityPlugin, the realSharingServiceand sharing middleware, and the real platformmember_defaultseed. The object has an unset OWD (private). The one variable is the principal's read depth, on the same three rows:recordhr_reviewer(org)visible: false,decidedBy: sharinghr_reviewer(org)visible: false,decidedBy: sharinghr_reviewer(org)visible: false,decidedBy: sharingteam_lead(unit)visible: false,decidedBy: sharingdept_reporter(own), the controltrue/true/falseResult:
Tests 7 failed | 17 passed (24). The seventh red is a second direction on the old binding. A__readScope: 'org'already present on the explained context decided the report (dept_reporter× unshared: explaintrue, find refused), because the bare context was passed straight through.The change
sharingReadFilterbinding callsresolveSharingReadFilter(o, { ...c, __readScope: undefined }). The caller's own key is cleared first, so the depth is always the computed one, the way step 2.6 overwrites it. Where resolution yields nothing (no sets, or a failed resolution), the helper stamps nothing and plugin-sharing readsown, the safe direction.sharing.buildReadFilter(o, c)unchanged, as fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 did on the write side. The middleware's delegated read (the agent-leg depth, plus a second filter AND-ed in under the delegator's identity) is not modelled on explain's record path. Stamping the agent's own depth without that second filter could only widen the report.delegatedWriteis renamedactsOnBehalfOf: it now guards read and write bindings, anddelegatedis already a name inside this function (the delegated-admin gate).Tests:
explain-read-verdict-inputs.test.ts(24 cases)Every cell asserts that
record.visibleforreadequals whether the find returns the row, and it asserts the find's own answer too. A cell cannot go green because both sides drifted. The find runs through both real middlewares, and the engine double evaluates the composed ASTwhere. The double's matcher throws on an operator it does not know, so a refused cell cannot go green because the double silently answeredfalse. Both sides read one hierarchy resolver:unit= the lead and the reporter.hr_reviewer(org) × shared / owned / unshared;team_lead(unit) × owned inside the unit / owned outside it / shared to someone else;dept_reporter(own) × read-shared / owned / unshared. Negative control:dept_reporter× unshared, refused by both, and explain namessharing(layerexcluded).orgreader's sharing layer isadmittedwithrowFilter: null. A__readScopethe caller brings does not decide the report, in either direction:dept_reporter+'org'× unshared is refused by both, andhr_reviewer+'own'× unshared is admitted by both.__readScope.public_read: all nine principal × row cells admit on both sides.Unfixed tree:
Tests 7 failed | 17 passed (24). Fixed (461187a0d2) and at the final head:Tests 24 passed (24).Ablations
The fix was committed first (
461187a0d2). Each mutation usedscripts/ablation-replace.mjs: the anchor went from 1 hit to 0, the replacement from 0 to 1, and the blob changed on disk. After each restore the blob matched HEAD (229605a70e3a) andgit diff HEADwas empty. The test imports./security-plugin.jsfrom source, so there is nodist/hop andablation-dist-preflightdoes not apply. Directions were predicted before each run.sharing.buildReadFilter(o, c)for every context7 failed / 17 passed:hr_reviewer× 3,team_lead× owned inside the unit, theorglayer pin, and both caller-depth pins. The widening one readsu_reporter × unshared × read: … expected true to be false. The plainvisible: falsecells stay green, as predicted: the old binding is narrower and cannot widen.__readScope: 'org'5 failed / 19 passed: everyvisible: falsepin (team_lead× unshared,team_lead× shared,dept_reporter× unshared, the negative control, the widening caller-depth pin), eachexpected true to be false.1 failed / 23 passed: the on-behalf-of pin (no read depth stamped on a delegated context: expected true to be false).resolveSharingReadFilter(o, c)24 passed, as predicted. The clear matters only where depth resolution yields nothing, and there explain's own CRUD layer already refuses, sorecord.visiblecannot show it. It is declared, unpinned hygiene.The first C attempt was a no-op. Its replacement text was a substring of its anchor, so
ablation-replacesaw the replacement count stay at 1, refused with exit 1, and ran no tests. The re-run with a distinct marker is the row above.Local verification: final head
8644de0cbepnpm --filter '@objectstack/plugin-security...' build: exit 0.vitest run:Test Files 124 passed,Tests 2365 passed, at6471f5564d. Later commits add only the ledger row and amainmerge whose source changes sit inpackages/mcpandpackages/runtime, outside plugin-security's build closure. The three explain suites re-ran at8644de0cbe:Tests 138 passed (138).pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new test is intsconfig.test.json's program (--listFiles: 1 hit) andcheck:test-typecheckis OK.dispatch-gates --commandsat8644de0cbe, reconciled with--ran: 70 derived, 68 run with exit 0, 2 NOT MEASURED.check:dual-build-cjs-loadsandcheck:type-check-debtexit 3 (PREREQUISITE NOT MET): they need every workspace package'sdist/, which CI's Build Core owns.check:i18nwas measured after building its declared closure: exit 0, 9 packages in sync.GITHUB_TOKEN=… node scripts/check-issue-citations.mjs: exit 0, 1 citation, resolves.--no-inline-config --format json) over the two changed TS files: 2 files, 0 errors, 0 warnings. Both files resolve a non-empty config under--print-config.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move the verdict of any untouched file.mainmerges before opening (ccccdcc35ethenbfa23a8f49then615c0856ef). Neither touched plugin-security, plugin-sharing, the ledger or the lockfile. fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988 had not landed.Surface note
scripts/engine-double-contract.pinned.jsongains one row (+5 lines: the new test file'sfindOnedouble). It was written bynode scripts/check-engine-double-contract.mjs --write, which that gate prescribes for a new pinned double ("0 added or grown, 0 lost"). It lies outside the claim's declared file surface, the same as the three rows #19984 added. #19988 does not touch this file.Acceptance notes
readvisibility. This predates the PR, which does not change it.explain-engine.ts:958catches a rejectedsharingReadFilterintonull, and the record matcher readsnullas "no filter". A one-off probe on this branch madesys_record_sharereads throw.dept_reporter× unshared then got explainvisible: true(sharingadmitted,rowFilter: null), while the same find threw. The old bare binding rejected into the same catch. This is reported for the seat to judge. It is not fixed here: the site isexplain-engine.ts, outside this card's surface.row-write-widener-composition.test.ts, and no cell here depends on a nested read.Generated by Claude Code