Repository navigation
fix(plugin-approvals): the generic data door serves sys_approval_action and sys_approval_approver rows by their request's visibility (#22589) - #22613
Conversation
…on and sys_approval_approver rows by their request's visibility A read of either child table through the generic data door (find, findOne, count, aggregate) now keeps a row only when its request_id names a request the approvals door would serve the same caller: the same visibility source the sys_approval_request gate consumes (requestVisibilitySourceOf), keyed by request_id. A read pinned to one request asks the targeted shape; a row read by id resolves its request through findChildRequestId, which authorizeFileRead now shares. Fails closed; administrator and system reads are unnarrowed. The record-reader tier never anchors on a child-table row, as it never anchors on a request. Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
…proval-sibling-read-gates
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 d7af06e34040d2f70c24ded9d3269acfd60f5c27 && git checkout d7af06e34040d2f70c24ded9d3269acfd60f5c27
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d748ae80afe6ed63d1c61a51a69f4fb18280c844 7721d61e0c87e7bda67001a96d1a4b206d1659f0 && git checkout -B drift-repro d748ae80afe6ed63d1c61a51a69f4fb18280c844 && git merge --no-ff 7721d61e0c87e7bda67001a96d1a4b206d1659f0
node scripts/docs-audit/affected-docs.mjs --json d748ae80afe6ed63d1c61a51a69f4fb18280c844
|
Fixes #22589
Clause-②: no
What this does
A read of a request's two child tables through the generic data door (
find,findOne,countandaggregateon 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 gavesys_approval_requestthe 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'sbootStackwith the realSecurityPlugin, automation, the record-change trigger andApprovalsServicePlugin). 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.76bc1e03a3)e6d4665bc6)403 PERMISSION_DENIED403 PERMISSION_DENIEDsys_approval_action200)404 RECORD_NOT_FOUNDsys_approval_approver200404 RECORD_NOT_FOUND404200On 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
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 privatevisibleRequestIds: 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 onrequest_idbuilt only from the ids it returned.requestChildReadTarget):request_idpinned (a record page's Timeline) is the request loaded by id, which carries its own anchor, aslistActionsreads it.idpinned (a row read by id) is the row loaded by id: the source resolves the row's request throughfindChildRequestId, whichauthorizeFileReadnow 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.bindVisibilityGate(module-private) holds the middleware body;bindRequestReadGatekeeps its signature and behaviour (keyed onid), and the children are keyed onrequest_id.requestVisibilityFiltertakes the column.404 RECORD_NOT_FOUND; a list'stotaland a grouped count are narrowed like its rows.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.ApprovalService(its published type is unchanged);src/index.tsis untouched;requestVisibilitySourceOfstays a module export the package entry does not re-export; nopackages/specpath. Who may decide is untouched: no decision path is edited.ApprovalsServicePlugin.start()besidebindRequestReadGate, outsidedisableAutoHooks, 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_actionkeyed onrequest_id. Pinned inrequest-child-read-gate.integration.test.ts:listActions, tier off and tier on;authorizeFileReadadmits 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 realApprovalsServicePlugin.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.Ablations, each from the committed head
7721d61e0cthroughscripts/ablation-replace.mjs(anchor hit x1 to x0, blob changed; restored to the HEAD blob,git diff HEADempty, re-proved after each leg by the driver's own trap-armed check). The subject resolves through relative source imports, so nodist/is on the path and no rebuild applies. Run on both gate files:approvals-plugin.tsexpected { ids: [ …(4) ], total: 4 } to deeply equal { ids: [], total: +0 }expected 9 to be 1(re-entry until the fuse at depth 9)expected null to be 'aact_…')request_idno longer asks the targeted shapeWith 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 mergesorigin/maind748ae80af, 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-typecheckholds 8 files / 324 errors / 27 signatures; the new file adds none.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 67; all 67 ran, each exit 0.--ranwith recorded exit codes: 67 derived, 67 run, 0 NOT-MEASURED (a derived zero).eslint --no-inline-config --format jsonover the 4 changed.tsfiles gives 4 files, 0 errors, 0 warnings. The population is read fromeslint.config.mjs(isPathIgnoredfalse for all four). NoparserOptions.projectorprojectServiceis 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 declaresget/liston the generic door with no read middleware, and its rows carryrequest_id,approver_id,token_hashandexpires_at. Measured: the control answers403; with a grant,200with 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.403on 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 plugin-approvals:sys_approval_requesthas no parent-record read gate on the data door, and the ruled record-reader tier cannot be switched on by an app (17.7.0) #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.request_idconjunct (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).authorizeFileReadnow reads the row's request throughfindChildRequestId, with arequest_idprojection added and its behaviour unchanged. Its existing tests inapproval-service.test.tspass in the full suite.sys_approval_actionorsys_approval_approverinrecordReaderVisibleObjects. No in-repo caller does, and theservecommand passes no options to the plugin.plugin-approvalsfrom source; the one dogfood reader of these tables (position-address-readers) reads as the system, which is unnarrowed.Generated by Claude Code