Repository navigation
Commit b1d701b
fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's (#22847)
Fixes #22792
Clause-②: no
This PR delivers item 2 of the card, built to ruling A of the
explain-asker decision (ruling record `6107185582` on #22795, maintainer
「同意」; director pointer `6107202712`). Item 1 landed as #22813, so this
PR closes the card. Claim `6108485513`.
## What this changes
Class: explain's disclosure of a record's existence. Positions: the
record pass of the explain engine
(`packages/plugins/plugin-security/src/explain-engine.ts`), the
caller-authorized explain (`explainAccessForCaller` in
`security-plugin.ts`), and two description strings in
`packages/spec/src/security/explain.zod.ts`.
- **The asker gate.** Every record-grained explanation first asks the
read door's own by-id read, `recordAbsentToCaller` (the question the
write doors ask), with the ASKER's context. An asker the door does not
return the record to gets, for every operation, exactly what an id no
row carries gets: `record` with no decider, and every layer's per-record
attribution the missing record's, through the one missing-record path
#22791 built. There is no exemption beside the door's answer: a system
asker is asked too, and the door returns it every row.
- **An asker who can read the record** gets the explained principal's
full record story:
- `read` and `export`: the principal's own verdict, decider included
(item 1's master-leg verdict among them), unchanged.
- a by-id `update`, `delete` or `transfer` (through `rlsOp`) of a row
the principal cannot read: `visible: false`, because the principal's
write answers what a nonexistent id answers, with the decider of the
principal's READ. Each row-scoped layer carries that read's attribution,
prefaced with the write's `404 RECORD_NOT_FOUND`, so the decider and the
layer trace agree (the spec defines the decider as the layer whose
`record.outcome` excluded the record).
- The ruling's confidence gap, measured: one write-operation pass cannot
yield the read's decider. It composes the write's row-level security,
the write gate, the write depth and the modify bypass, and none of them
is the read's (shown below, where the write composition admits the row
and the read excludes it). So a second, read-operation pass of the
engine yields it, asked only in that arm.
- **Self-explanation** (the asker is the explained principal): an
unreadable row is the missing record for every verb. On the write verbs
that is what #22791 already answered. On `read` and `export` it is new:
the card's item 2.
- **Batch** (`recordIds`): the REST route answers each id through the
singular service call, so it follows by its own definition. Pinned
below.
- **Spec text**: the `record` verdict's description and the `recordIds`
"Missing records" clause now say that an id the asker cannot read
answers as missing, alongside filtered, deleted and never-existed ids.
Description text only; the reference page is regenerated by the spec's
own tooling (`check:generated --fix`, which ran `gen:docs`).
### No exported type moves (Clause-② no)
- The asker reaches the engine through a module-internal entry,
`explainAccessForAsker` in `explain-engine.ts`, which the package index
does not export. The published `explainAccess(deps, input)` keeps its
signature and answers a self-explanation. `ExplainEngineDeps`,
`ExplainInput` and `SecurityPlugin.explainAccessForCaller` keep their
signatures (comment-only diffs there); `src/index.ts` is untouched.
- Measured on the built declaration (the package rebuilt at
`842f1ca03`): `dist/index.d.ts` still declares `explainAccess(deps:
ExplainEngineDeps, input: ExplainInput)`, its export list carries no new
name, and `explainAccessForAsker` occurs 0 times in `dist/index.d.ts`
and `dist/index.d.mts`.
- Why not a per-call binding of `recordAbsentToCaller`: the engine asks
that question of the explained principal too (the by-id write guard,
item 1's master leg), so rebinding it to the asker would judge the
principal's own outcome by the asker's read. The engine needs both
contexts at once, and the internal entry carries the second one without
widening a published type.
## Pins
New file
`packages/plugins/plugin-security/src/explain-asker-gate.test.ts`, 42
cases. The real `SecurityPlugin` over a real `ObjectQL`, the SQL driver
and the platform grant tables, on one `public_read_write` object. The
explained member's read policy hides one row and its write-class
policies admit every row, so the write composition alone narrows nothing
the read does.
- Armed: who reads which row, and the member's three write doors answer
`404 RECORD_NOT_FOUND` for the hidden row and for a nonexistent id.
- An asker who cannot read the row (the member itself; a `manage_users`
administrator held to the member's read; a D12 delegated administrator
held to it, whose boundary check is stubbed): for `read`, `export`,
`update`, `delete` and `transfer`, the hidden row's whole explanation
equals the nonexistent id's, layers included.
- A `manage_users` administrator who reads the row: `read` is the
member's verdict with its decider; each write verb is `visible: false`
decided by the member's READ (`rls`), the `rls` layer carries the read
policy's predicate (never the write composition's), every row-scoped
layer carries the read preface, and the only status any layer names is
the door's `404`. Both equal the system asker's answer.
- Controls: a nonexistent id is the missing record for every asker; a
row every asker reads gets one answer whoever asks.
- Engine level: which contexts the question is asked with, and how often
(once per context per explanation); a system asker is asked like any
other; a rejection of the asker's question fails the explanation (an
outage is neither absence nor a row); and the write arm's decider is the
READ's where the write composition admits the row.
Also: `explain-controlled-by-parent-write.test.ts` gains the write arm
on a `controlled_by_parent` record (decided by the principal's read on
the master leg, the master write check never reached, no layer naming
its `403`; control: a record the principal reads keeps the check's
refusal and its `403`). `cbp-explain-master-write.dogfood.test.ts` (the
family's REST parity pin) gains, beside the GET door, an administrator
who reads the record (the steward's verdict, decider included) and the
batch case (each `records[i]` equals the singular verdict, for the
steward and for the administrator).
## Pin sweep
Whole-repo grep for the same semantics (record-grained explain answers
for a row the asking principal cannot read, every `decidedBy` assertion
beside an explain call) over plugin-security, plugin-sharing, rest,
client, spec and the dogfood suite. Flipped, each to a load-bearing
assertion of the new answer:
- `explain-controlled-by-parent-write.test.ts`, the read block: item 1's
verdict cells now explain to an asker who reads the record, so the
decider stays pinned where ruling A keeps it; new self-explanation cells
pin the missing record's whole answer (equal to the no-row explanation),
and the read-question cells pin which contexts are asked.
- `explain-cross-class-refusal.test.ts` and
`explain-json-column-refusal.test.ts`, the controls: a self-explained
read of a row the caller cannot read now pins the missing record (no
decider, the `rls` record not evaluated, the whole explanation equal to
a nonexistent id's), matching the write side those cells already pinned.
- `cbp-explain-master-write.dogfood.test.ts`, the read row: the steward
explaining itself the unreadable record pins the missing record, whole.
Kept verbatim: every refusal-shape assertion (the `INVALID_FILTER`
refusals, the door statuses), #22791's record-story pins (they are
self-explanations and hold as written), and
`explain-write-door-parity.dogfood.test.ts` (self-explanations; its
object-gate cells keep `decidedBy: 'object_crud'`, which precedes the
missing-record branch).
## Ablation (one-time, no permanent file)
Each leg through `scripts/ablation-replace.mjs` in wrap mode (the anchor
hit 1 then 0, the blob changed, restore proven by blob equality with
HEAD and an empty `git diff HEAD`). The plugin-security suites import
`src/`; the dogfood suite reads `dist/`, so its legs rebuilt the
package, read the marker in `dist/` with `ablation-dist-preflight.mjs`
before the run, and after the restore rebuilt and read the marker absent
with a clean tree.
| leg | mutation | red | green |
|---|---|---|---|
| A | the asker gate off | 48 of 192 in five suites (every cannot-read
cell, the engine read-question cells, the flipped controls, #22791's
self-explanation pins) and 2 of 20 dogfood (the self-explained read, the
batch) | every control and every can-read cell |
| B | the write arm's read pass off | 10 of 141 (the write arm's
decider, layer and status cells, plugin and engine, and the
`controlled_by_parent` write arm) | everything else |
| C | the caller passes no asker | 7 of 42 (every can-read cell) and 2
of 20 dogfood (the administrator's read, the batch) | every cannot-read
cell and control |
Leg A ran on `842f1ca03`, after the asker check lost its system-asker
exemption (a system asker is asked like any other, and the read door
returns it every row); B and C ran on `46dcaa54a`; between it and the
head the engine changed only by that exemption, and the
`explainAccessForCaller` call is unchanged.
Quoted, record ids elided: `… to deeply equal { Object (recordId,
visible) }` (A, a cannot-read cell, told the stored row's verdict with
its decider) and `explain read for the steward: expected { recordId: ID,
…(1) } to deeply equal { recordId: ID, …(2) }` (C, the dogfood
administrator cell, told the missing record).
## Verification
All at `842f1ca03` unless named (the branch merged `origin/main` up to
`d8c7d3864`; a full workspace build ran after the merge).
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 198 files passed, 4057 tests passed, 45 skipped, exit
0.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (tsc,
scripts, and the test layer, `check:test-typecheck: OK`;
`tsconfig.test.json` includes `src/**/*`).
- `pnpm --filter @objectstack/dogfood exec vitest run
test/cbp-explain-master-write.dogfood.test.ts
test/explain-write-door-parity.dogfood.test.ts`: 2 files, 43 passed,
against the package's rebuilt `dist/`. `pnpm --filter
@objectstack/dogfood typecheck`: exit 0 at `8cf36e560` (the dogfood file
is unchanged since).
- At `8cf36e560` (the spec and rest sides are unchanged since):
`@objectstack/spec` `src/security/explain.test.ts` and
`explain-zero-rows-sentinels.pin.test.ts`, 38 passed;
`@objectstack/rest` `cross-class-refusal-remedy-on-the-wire`,
`security-explain-envelope` and `security-routes`, 47 passed.
- `pnpm --filter @objectstack/spec check:generated` named one stale
artifact (the explain reference page); `--fix` rebuilt the spec, ran
`gen:docs` and re-checked it green.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 115; all 115 ran, every one exit 0,
each exit code captured before any pipe (`check:generated`,
`check:docs`, `check:api-surface`, `check:nul-bytes`,
`check:system-context-census`, `check:empty-changeset`,
`check:dual-build-cjs-loads`, `check:i18n` and the
`check:type-check-debt` re-measure among them). `--ran` over the
`command :: exit code` record: 115 derived, 115 run, 0 NOT-MEASURED (a
derived zero), 0 UNRUN.
- Lint, narrowed and proved: (1) population, read from eslint's own
config: all 8 touched TypeScript files resolve under `eslint
--print-config`, and the changeset and the generated `.mdx` match no
configuration; (2) `eslint --no-inline-config --format json` over those
8 files: 8 files, 0 errors, 0 warnings; (3) invariance:
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no `projectService`), so this diff cannot move
any untouched file's verdict. Repo-wide `pnpm lint` is declared to CI.
- Control-byte self-scan over the changed files: 0 hits.
- Not run locally, declared to CI: repo-wide lint, the full dogfood
suite, the path-scheduled jobs.
## Acceptance notes
- **The read question is now asked for every record-grained
explanation**, of the asker (and, in the write arm, of the principal),
memoized per explanation: one caller-context by-id read more per
explanation than before on most verbs. A second engine pass runs only in
the write arm for an asker who reads the record.
- **A principal refused at the object gate** whose row is absent to it
now gets the missing record's layers too. Its verdict is unchanged (the
capability and CRUD deciders precede the missing-record branch).
- **The published `explainAccess`** (a self-explanation, for a direct
embedder that wires `recordAbsentToCaller`) answers a `read` or `export`
of a row the principal cannot read as missing, as the security service
now does.
- **Drift case, NOT MEASURED as reachable:** if the principal's read
story admits a row its read door withholds (a leg explain does not
model), the write arm's verdict carries no decider, and the layers show
that read's admitting attribution under the preface.
- **Item 1's pending release note**
(`.changeset/22792-explain-read-master-leg.md`) says a withheld
`controlled_by_parent` record "is reported not visible, decided by the
`sharing` layer". Under ruling A that holds for an asker who can read
the record. This PR leaves that note untouched (correcting a pending
note is a release decision, and the empty-changeset gate keeps such a PR
red) and its own note states the qualification. If the seat prefers the
pending note corrected in place, that is the gate's
deliberate-correction path.
- **Open question for the seat (not built): a refused asker.** Under the
ruled notion, `recordAbsentToCaller` answers `true` only for absence; a
declared 4xx refusal of the asker's own read (for example, no read grant
on the object at all) answers `false`, so that asker is answered as one
who can read. Its read door answers the same refusal for a stored row
and a nonexistent id, while explain answers them differently. The
ruling's headline and its D12 bullet read the other way; its
sub-question 4 names this exact question. Built as ruled; the options
are in the report.
### Patch round: ruling C of #22851 (decision record `6110031776`)
- **What changed.** The asker gate classifies the asker's caller-context
by-id read by asker kind, on that one read:
- An asker explaining another user (a `manage_users` administrator, a
D12 delegate): no row, or a declared 4xx refusal of its own read, is
"cannot read", and it gets, for every operation, the answer an id no row
carries gets. Position: `unreadableUnderCallerRead` in
`security-plugin.ts`, beside `absentUnderCallerRead`, over the shared
`readUnlessRefused` classification; a store fault still fails the
explanation.
- A principal explaining itself keeps the write path's notion
(`recordAbsentToCaller`: absent means no row), so a refused read leaves
its explanation as its own doors answer.
- The engine carries the asker as `{ context, cannotRead }` on the
module-internal `explainAccessForAsker` (its `ExplainAsker` type is not
exported from the package index). `explainAccess`, `ExplainEngineDeps`,
`ExplainInput` and `SecurityPlugin.explainAccessForCaller` keep their
signatures. Measured on the built `dist/index.d.ts` at `026209205`: none
of the new names occurs, and `explainAccess` is declared as before.
`Clause-②: no` holds.
- **Spec text and changeset.** The `record` verdict's description and
the `recordIds` "Missing records" clause in `explain.zod.ts` now say
which ids each asker cannot read (explaining another principal: no row,
or a refusal; a principal explaining itself: no row, a refused read not
counting). The reference page is regenerated with the spec's own
`gen:docs`; the changeset says the same.
- **Pins** (`explain-asker-gate.test.ts`, now 62 cases):
- a `manage_users` administrator and a D12 delegate whose own reads of
the object are refused, for `read`, `export`, `update`, `delete` and
`transfer`: a stored row's whole explanation equals a nonexistent id's,
layers included (a row the explained user cannot read, and one it
reads);
- CONTROL: a principal that may edit the object but not read it,
explaining itself, keeps the stored row's story (equal to a system
asker's account of that principal) and, on the write verbs, `visible:
true` beside its own door's admission;
- CONTROL: a reading administrator and a nonexistent id are unchanged;
- engine level: the third-party classification decides for another
principal's explainer, a self-explanation is never asked it, and a store
fault in it fails the explanation.
- **A CI regression in this round, its cause and fix.**
`explain-enforce-parity.test.ts` went red at `6cc56c243` (7 of 98).
Cause: that rig's explaining administrator held `manage_users` and no
read grant on the probed tenant object, so its own by-id read of the
record is refused (measured: a declared 4xx). Under ruling C that asker
is told the nonexistent id's answer, while those cells hold explain's
record verdict against the explained user's door. The classification is
right: a third-party asker, read with its own context, genuinely
refused. Fix, fixture only: the rig's administrator also holds the
reader set the explained users hold, so it reads what it explains and
the record cells keep measuring the explained user's story. The five
failing record cells are byte-identical. The two `single`-posture record
rows for the other organization's row (a measured divergence, asserted
as a disagreement) turned to parity, because the asker's own read is
scoped to the context organization the same way; per the file's own rule
those rows joined the invariant (their divergence marker removed), and
the `readFilter` divergence row stays.
- **Ablation** (`scripts/ablation-replace.mjs`; restore proven by blob
equality with HEAD and an empty `git diff HEAD`; the subject resolves
through `src/`, so no `dist/` leg): the plugin's refused-asker
classification put back to the absence notion turned 10 of 161 red
(every refused-asker cell) with every control green; the engine's
third-party branch switched to the write path's question turned 12 of
161 red (those 10 and the two engine classification cells) with the
controls green. Both at `6cc56c243`; the mutated lines are unchanged at
the head.
- **Hand-written page re-verified**
(`content/docs/permissions/explain.mdx`, listed by the Docs Drift
Check): read "What a decision looks like" (lines 15-45), "Calling it"
(47-84), "Who may ask" (86-100) and "What it powers" (102-112). It names
no record verdict, no `recordId` or `recordIds`, and no missing-record
definition; its "another user" rule is the request's authorization,
which rulings A and C leave as it is. Every sentence holds, so it is not
edited.
- **Verification at `026209205`** (the branch merged `origin/main` to
`0253192f7` through `scripts/pm/os-regen-merge.sh`, then a full
workspace build): the plugin-security suite, 197 files, 4045 passed, 45
skipped; plugin-security typecheck exit 0; dogfood
`cbp-explain-master-write` and `explain-write-door-parity`, 43 passed,
and dogfood typecheck exit 0; gates, 115 derived by `dispatch-gates
--commands`, all 115 run and exit 0 (`check:docs` and `check:generated`
among them), `--ran` 0 NOT-MEASURED and 0 UNRUN; narrowed lint, 9 files,
0 errors and 0 warnings (population read from eslint's own config, no
type-aware linting); control-byte scan 0.
- **Acceptance note update:** the body's "Open question for the seat: a
refused asker" is answered by ruling C and built in this round.
---
_Generated by [Claude
Code](https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 40bc51f commit b1d701b
11 files changed
Lines changed: 1062 additions & 63 deletions
File tree
- .changeset
- content/docs/references/security
- packages
- plugins/plugin-security/src
- qa/dogfood/test
- spec/src/security
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
108 | 108 | | |
109 | 109 | | |
110 | 110 | | |
111 | | - | |
| 111 | + | |
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
| |||
0 commit comments