Repository navigation
Commit 83b8b80
Fixes #22589
Clause-②: no
## What this does
A read of a request's two child tables through the generic data door
(`find`, `findOne`, `count` and `aggregate` on the engine, so the REST
data routes, the export route, the MCP data tool and every other engine
read) now returns only the rows whose request the approvals door
(`/api/v1/approvals/*`) serves the same caller:
- `sys_approval_action`, the decision log (actor, decision, comment
text);
- `sys_approval_approver`, the pending-approver index.
This is the sibling half of #22559 (PR #22587, `76bc1e03a3`), which gave
`sys_approval_request` the same rule. The default (no grant, `403`) is
unchanged.
## Step 1: reach, measured before any code
Measured through the real HTTP data routes on an in-process stack
(`@objectstack/verify`'s `bootStack` with the real `SecurityPlugin`,
automation, the record-change trigger and `ApprovalsServicePlugin`). The
scene: one approval flow, two submitters each with one pending request,
one position-holding approver, and members granted read on one child
table at a time through an app permission set. The probe was a local
scratch file and is not part of this PR.
| caller (class) | table read | before (`76bc1e03a3`) | after
(`e6d4665bc6`) |
|---|---|---|---|
| member, no grant (control) | either table: list, a request's list, by
id | `403 PERMISSION_DENIED` | `403 PERMISSION_DENIED` |
| member with a read grant, takes part in neither request |
`sys_approval_action` | both requests' rows by list (total 2), by
another request's pin, and by id (`200`) | 0 rows (total 0); by id `404
RECORD_NOT_FOUND` |
| same, grant on the other table | `sys_approval_approver` | both
requests' rows (total 2); by id `200` | 0 rows; by id `404
RECORD_NOT_FOUND` |
| submitter of one request, with both grants | either table | the other
submitter's rows too (by list and by id) | own request's rows only; the
other's by id `404` |
| current approver of both, with both grants | either table | (no grant
in the first run) | both requests' rows, each request's Timeline 1 row,
by id `200` |
On both runs the approvals door listed 0 requests and 0 history rows for
the non-participants, so the generic door was serving rows of requests
the approvals door does not show the caller. `sys_approval_token` (a
third request-keyed table) had no rows in this scene, so its reach is
NOT MEASURED; see the acceptance notes.
## One definition, both doors
- **No second rule.** The child-table gates
(`bindRequestChildReadGates`, `request-read-gate.ts`) ask the same
in-package source the parent's gate asks,
`requestVisibilitySourceOf(service).visibleRequestIdsFor(...)`, which is
the approvals door's private `visibleRequestIds`: the participant set,
the override actor's unrestricted view (`null`, so administrators and
system reads are never narrowed and the answer costs no read), and the
default-OFF record-reader tier on top. The gate ANDs a conjunct on
`request_id` built only from the ids it returned.
- **Which request a read names** (`requestChildReadTarget`):
`request_id` pinned (a record page's Timeline) is the request loaded by
id, which carries its own anchor, as `listActions` reads it. `id` pinned
(a row read by id) is the row loaded by id: the source resolves the
row's request through `findChildRequestId`, which `authorizeFileRead`
now shares, so the two doors cannot disagree about which request a row
belongs to. Anything else is untargeted, the participant set alone, so
the inbox shape is not widened by the tier.
- **The parent's gate and the children's are one binder.**
`bindVisibilityGate` (module-private) holds the middleware body;
`bindRequestReadGate` keeps its signature and behaviour (keyed on `id`),
and the children are keyed on `request_id`. `requestVisibilityFilter`
takes the column.
- **Fails closed.** A failed visibility answer denies the read and logs
the same warning as the parent's gate, now with the object in its
metadata. A row with no request is in no set.
- **Absence, not refusal.** A row the caller may not see answers a by-id
read as a missing one does, `404 RECORD_NOT_FOUND`; a list's `total` and
a grouped count are narrowed like its rows.
- **The tier never anchors on a child-table row**
(`addRecordReaderVisibleIds`), as it already never anchored on a
request. The anchor read runs as the caller, so on a child table it
would pass through that table's gate, which asks about the request the
row belongs to, whose anchor can be that very row. Ablation B below
measures the unbounded re-entry this prevents.
- **Published surface unchanged.** No member is added to
`ApprovalService` (its published type is unchanged); `src/index.ts` is
untouched; `requestVisibilitySourceOf` stays a module export the package
entry does not re-export; no `packages/spec` path. Who may decide is
untouched: no decision path is edited.
- Bound in `ApprovalsServicePlugin.start()` beside
`bindRequestReadGate`, outside `disableAutoHooks`, as the parent's is.
An engine without a middleware seam gets one boot warning naming all
three tables.
## The record page's Timeline (the #22567 regression pin)
The request record page lists `sys_approval_action` keyed on
`request_id`. Pinned in `request-child-read-gate.integration.test.ts`:
- the submitter, the current approver and an administrator each keep the
request's Timeline rows, by the implicit filter and by the filter-array
spelling;
- for every caller (non-participant, record reader, both submitters,
approver, administrator) and both requests, the Timeline's rows equal
`listActions`, tier off and tier on;
- by id, an action row is served exactly when `authorizeFileRead` admits
it, tier off and tier on.
## Pins and ablations
New file
`packages/plugins/plugin-approvals/src/request-child-read-gate.integration.test.ts`,
on the parent's rig: a real ObjectQL engine over better-sqlite3, the
real `ApprovalsServicePlugin.start()`, and the real data-door normalizer
(`ObjectStackProtocolImplementation.findData` / `getData`). 25 tests:
per table, a non-participant sees nothing (list, a request's list,
count, grouped count), a hidden row by id answers as a missing one, a
participant sees only their own request's rows, an administrator and
system reads are unchanged; the Timeline pins above; tier on, per table;
fail closed, per table; the re-entry fuse, per table.
- **Red first:** on the unmodified code the new file ran 17 failed and 8
passed. The 8 are the positives and controls: scene, administrator,
system, Timeline readers, tier-on readers.
- **Green:** with the fix, both gate files ran 41 passed (25 new and 16
of the parent's).
Ablations, each from the committed head `7721d61e0c` through
`scripts/ablation-replace.mjs` (anchor hit x1 to x0, blob changed;
restored to the HEAD blob, `git diff HEAD` empty, re-proved after each
leg by the driver's own trap-armed check). The subject resolves through
relative source imports, so no `dist/` is on the path and no rebuild
applies. Run on both gate files:
| | mutation | result |
|---|---|---|
| A | child gates unwired in `approvals-plugin.ts` | 17 failed / 24
passed: every negative pin of both tables, both parity pins, the
fail-closed pins and the fuses. First: `expected { ids: [ …(4) ], total:
4 } to deeply equal { ids: [], total: +0 }` |
| B | the tier's anchor guard no longer covers child tables | 2 failed:
both fuses, `expected 9 to be 1` (re-entry until the fuse at depth 9) |
| C | the gate's catch fails open | 3 failed: the parent's and both
children's fail-closed pins |
| D | a row read by id no longer resolves its request (untargeted) | 3
failed: the tier-on by-id pins for both tables and the tier-on parity
pin (`expected null to be 'aact_…'`) |
| E | a read pinned by `request_id` no longer asks the targeted shape |
3 failed: the tier-on request-list pins for both tables and the tier-on
parity pin |
With the tier off, the targeted and untargeted answers are identical, so
D and E move tier-on pins only. That is the expected direction.
## Verification (head `7721d61e0c`, which merges `origin/main`
`d748ae80af`, a spec-only commit)
- `pnpm turbo run build --concurrency=1`: 73 tasks, exit 0.
- `pnpm --filter @objectstack/plugin-approvals exec vitest run
--maxWorkers=2`: 67 files, 988 passed, exit 0.
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0.
`check:test-typecheck` holds 8 files / 324 errors / 27 signatures; the
new file adds none.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 67; all 67 ran, each exit 0. `--ran`
with recorded exit codes: 67 derived, 67 run, 0 NOT-MEASURED (a derived
zero).
- Lint, narrowed: `eslint --no-inline-config --format json` over the 4
changed `.ts` files gives 4 files, 0 errors, 0 warnings. The population
is read from `eslint.config.mjs` (`isPathIgnored` false for all four).
No `parserOptions.project` or `projectService` is set, so type-aware
linting is off and this diff cannot move any untouched file's verdict.
## Acceptance notes
- **`sys_approval_token`, same family, not folded in.** It declares
`get` / `list` on the generic door with no read middleware, and its rows
carry `request_id`, `approver_id`, `token_hash` and `expires_at`.
Measured: the control answers `403`; with a grant, `200` with 0 rows,
because no action-link token existed in the scene, so its reach is NOT
MEASURED. It is left out because who may see token rows is a policy
question rather than a mechanical extension: the approvals door serves
token rows to nobody, while request visibility would serve them to
participants. Reported to the seat.
- **On a default deployment the record page is denied to its declared
audience.** No baseline set grants read on the three approval tables, so
a request's submitter and approvers get `403` on the request's own
data-door read and on its Timeline unless an app grants read. Measured
in both probe runs; it predates this PR, and #22559's card measured the
request half. With all three tables now narrowed to request visibility,
a baseline grant would serve only visible rows. Whether to add one is a
product call outside this PR.
- **A row read by id carries the caller's whole visible set** as its
`request_id` conjunct (the row's request is resolved inside the source,
not in the gate). This is correct but wider in SQL than the parent's
one-id by-id conjunct. It is bounded by the participant probes' window
(`APPROVER_INDEX_CAP`).
- **`authorizeFileRead` now reads the row's request through
`findChildRequestId`**, with a `request_id` projection added and its
behaviour unchanged. Its existing tests in `approval-service.test.ts`
pass in the full suite.
- **The anchor-guard extension narrows the approvals door too**, only
for a deployment that names `sys_approval_action` or
`sys_approval_approver` in `recordReaderVisibleObjects`. No in-repo
caller does, and the `serve` command passes no options to the plugin.
- Declared to CI: the dogfood suites. They resolve `plugin-approvals`
from source; the one dogfood reader of these tables
(`position-address-readers`) reads as the system, which is unnarrowed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29a163f commit 83b8b80
5 files changed
Lines changed: 593 additions & 30 deletions
File tree
- .changeset
- packages/plugins/plugin-approvals/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 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
88 | | - | |
| 88 | + | |
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| |||
1298 | 1298 | | |
1299 | 1299 | | |
1300 | 1300 | | |
| 1301 | + | |
| 1302 | + | |
| 1303 | + | |
| 1304 | + | |
| 1305 | + | |
| 1306 | + | |
| 1307 | + | |
| 1308 | + | |
| 1309 | + | |
| 1310 | + | |
| 1311 | + | |
| 1312 | + | |
| 1313 | + | |
| 1314 | + | |
| 1315 | + | |
| 1316 | + | |
| 1317 | + | |
| 1318 | + | |
| 1319 | + | |
| 1320 | + | |
1301 | 1321 | | |
1302 | 1322 | | |
1303 | 1323 | | |
| |||
1382 | 1402 | | |
1383 | 1403 | | |
1384 | 1404 | | |
| 1405 | + | |
| 1406 | + | |
| 1407 | + | |
| 1408 | + | |
| 1409 | + | |
| 1410 | + | |
1385 | 1411 | | |
1386 | 1412 | | |
1387 | 1413 | | |
| |||
1392 | 1418 | | |
1393 | 1419 | | |
1394 | 1420 | | |
1395 | | - | |
1396 | | - | |
1397 | | - | |
| 1421 | + | |
| 1422 | + | |
| 1423 | + | |
| 1424 | + | |
| 1425 | + | |
| 1426 | + | |
| 1427 | + | |
1398 | 1428 | | |
1399 | 1429 | | |
1400 | 1430 | | |
| |||
6859 | 6889 | | |
6860 | 6890 | | |
6861 | 6891 | | |
6862 | | - | |
| 6892 | + | |
| 6893 | + | |
| 6894 | + | |
| 6895 | + | |
6863 | 6896 | | |
6864 | 6897 | | |
6865 | 6898 | | |
| |||
7271 | 7304 | | |
7272 | 7305 | | |
7273 | 7306 | | |
7274 | | - | |
7275 | | - | |
7276 | | - | |
7277 | | - | |
7278 | | - | |
7279 | | - | |
| 7307 | + | |
| 7308 | + | |
| 7309 | + | |
7280 | 7310 | | |
7281 | 7311 | | |
7282 | | - | |
| 7312 | + | |
7283 | 7313 | | |
7284 | 7314 | | |
7285 | 7315 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
| 33 | + | |
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| |||
317 | 317 | | |
318 | 318 | | |
319 | 319 | | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
320 | 323 | | |
321 | | - | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
322 | 327 | | |
323 | 328 | | |
324 | | - | |
325 | | - | |
326 | | - | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
327 | 332 | | |
328 | 333 | | |
329 | 334 | | |
| |||
0 commit comments