Repository navigation
fix(plugin-approvals): a request's subject title is served only from fields its reader may read - #23614
Conversation
…er may read The inbox's record_title is narrowed by the reader's served subject fields, the one answer the payload snapshot is redacted by; the action-link pages, which have no reader to narrow for, name the record by its id. Claude-Session: https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 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 282e7a68d46e2e0e4676379ff0dea1a7a6073308 && git checkout 282e7a68d46e2e0e4676379ff0dea1a7a6073308
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8d05456cbe03667ea2352c9a99e67d4a862a8294 d3ca88e8d36a1fabff23b4dbe9c9284a02f50f5f && git checkout -B drift-repro 8d05456cbe03667ea2352c9a99e67d4a862a8294 && git merge --no-ff d3ca88e8d36a1fabff23b4dbe9c9284a02f50f5f
node scripts/docs-audit/affected-docs.mjs --json 8d05456cbe03667ea2352c9a99e67d4a862a8294
|
Contract reviewServed-tier: Read: card #23594 (body and all three comments: triage ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged.
Check-runs on the head, as read (latest per name): 33 completed (28 success, 5 skipped by design: Build Docs, Console Pin Gate, Dogfood matrix shard, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance). Green: Check Changeset, Check PR Size, Governed Surface Queue Guard, every Lint family, every Type Check family, Build Core, Dogfood Regression Gate, Test Core (5/6), the claim and single-writer guards. In progress at read time: Test Core (1/6), (2/6), (3/6), (4/6), (6/6). None failed. The queue's "every check green" condition is the seat's to read at landing time; this verdict is on the contract. ② Semver levelChangeset
Governed surfaces: none in the file list (no ③ Boundary flagsOpen question 1 (a reader whose title is withheld sees the nine success messages render an empty quoted title; A carry, B a server-named always-present display field, C a fallback operator in the action runner's token grammar). Answered: A, as built. It leaks nothing (the token reads the reader's own narrowed row), the inbox's inline copy already falls back to the formatted id, and the same shape already toasts for any subject with no resolvable title (920bc12). B adds a second name field to the spec contract beside Open question 2 (the action-link pages withhold the title from every bearer, a reader who could read it included; A keep, B narrow under the token slot's person via a reconstructed grant envelope). Answered: A. The page's row is read under the system context, which field-level security cannot narrow, so serving the title there would serve exactly the value this card withholds. B is a second derivation of a person's authorization envelope beside the session's (the shape the seat declined for the notification on #23556), a one-route-one-owner breach with no measured pull. Ruling C's floor for an approver (the inbox entry, the request detail redacted by FLS, and the approve and reject actions) is kept: the pages still decide. Escalated as a residual for the card close: the card's positive control ("an approver who can read the field still gets it") holds on the inbox row, request detail and toast, and NOT on the one-tap page, where every bearer now sees the record id. That is a declared narrowing, named in the changeset, not a leak and not a grant. Deviation 1 (push order: the pre-fix red run, fix and ablations ran on local commits; one content push carried fix, pins and changeset). Answered: the security rule that nothing red lands on the remote before the fix governs; no contract effect. Deviation 2 (four test files beyond the claim's named sources). Answered: all four are tests of the claimed sources, inside the claim's "and their tests"; each is named in the PR body with its reason. Within surface. Deviation 3 (model-free commit trailers and the repository's session footer instead of the harness reminder's). Answered: a process note with no contract effect; the repository's instructions govern the seat. Out-of-scope notes, each answered or escalated:
One sibling finding of this review (function level, outside the card's scope, no expansion asked): Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对
Verdict: ACCEPT at head
Generated by Claude Code |
Fixes #23594
Clause-②: no
A request's subject title (
record_title) is now served only from fields its reader may be served. The one-tap action-link pages, which have no reader to narrow for, name the record by its id. Function level only, per the card.What changed
All in
packages/plugins/plugin-approvals.ApprovalService.servedSubjectFieldsasksresolveReadableSnapshotFields(payload-redaction.ts, the [finding] approvals:sys_approval_request.payload_jsonsnapshots the full raw row and serves it to approvers unfiltered — object-levelhidden: trueand FLS declarations cannot reach inside the JSON blob #10749 seam: readable, and not masked for this caller) once per subject object of a read, as the reading caller. Both serve-time narrowings of a row read this map:redactPayloadsredacts the payload snapshot with it, as before (now synchronous, fed the map);resolveSubjectDisplayspicks the title from the subject record as served to the reader:redactSnapshot(record, served), then the existingpickTitle.enrichRowstakes the map as a required argument, so no caller can reach the title without stating the narrowing.getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964), gives no title: the key is absent, the shape the redaction gives that field in the payload. A title-eligible field the reader may read (name,title,subject,label) still names the record, which is how the snapshot fallback already picked its title. A reader who may read the display field gets the same title as before. The seam's pass-through (no security service wired, an unresolvable schema, a throwing read projection) passes the title through exactly as it passes the snapshot. Nothing is granted: record access stays with object permissions, sharing and row-level rules (Design: does approver routing imply record read visibility? (#7345 model half) #7497 ruling C).action-link-pages.ts,summaryRows). The "Record" row shows the record id beside the object's label, never the title, on both the confirm and the result page, through both doors (the self-hosted mount andhandleActionPage).The dispatch's mechanism assumptions, measured on
origin/maina6d0715535listRequestsandloadRequestalready redacted the payload by the caller (redactPayloads), thenresolveSubjectDisplaysread the subject underSYSTEM_CTXand picked the title from the whole record, once per object, for every reader. The narrowing now sits where the payload's does, with the same answer.pickTitle(r.payload)inenrichRows) reads the payload afterredactPayloadshas redacted it for the reader, so a deleted subject never leaked a withheld snapshot field. Pin (b) is green on the old code and on the new.readBackRequesttoloadRequest) is enriched under the acting caller's context, so the success copy'sresult.request.record_titletoken is narrowed by this change. Action-link pages: the token chain reads the request withgetRequest(..., SYSTEM_CTX)(peek) and decides under the system context with the slot's person attached (redeem). Field-level security does not narrow a system context (plugin-securityreturns the full set forisSystem), so the page cannot be narrowed for its bearer. Per the dispatch, the title is withheld there, not granted, for every bearer.sys_approval_request.payload_jsonsnapshots the full raw row and serves it to approvers unfiltered — object-levelhidden: trueand FLS declarations cannot reach inside the JSON blob #10749 header states the seam "does not gate on OBJECT-level access". Gating the title by exposure while the same field ships in the snapshot would be a second rule beside the redaction. 896a434 gated a SECOND object's titles (payload_displaylookup targets), which no approval routes the reader to. Whether exposure should gate the approval path at all is a separate design question.Pins
New
subject-title-reader-fls.integration.test.ts: the realApprovalServiceon a realObjectQLoverSqlDriver(in-memory better-sqlite3), with a field-visibility source standing in forplugin-security's.getRequestandlistRequests. The payload is narrowed by the same answer. A display field served masked is withheld the same way.Updated:
action-page-member.integration.test.ts, the POST parity twin case (a token is single-use, so twins now differ by their record ids alone; byte-equal once that difference is named, and each page equals the renderer's page for its own request).payload-display-target-exposure.test.tsandimplicit-reference-target-lookup.test.tspass the new required argument (an empty map: no answer for any object). One comment inapproval-requested-notification.integration.test.tsno longer says the inbox title is unnarrowed.Red before, ablations after
a6d0715535plus the pins: 5 failed / 3 passed. The failures were (a) twice, (c), (d) and (f); (b), (e) and (g) were green.67fd2c6bee, viascripts/ablation-replace.mjsunder an EXIT/INT/TERM trap. The served-view line inresolveSubjectDisplayswas replaced by the old whole-record pick. Predicted: red on (a), (a masked), (c) and (f). Observed: 4 failed / 4 passed, exactly those. Restore proven: blobdaeac5d639c7equals HEAD, andgit diff HEADis empty.record_title || record_idrow was put back insummaryRows. Predicted: red on (d). Observed: 1 failed / 7 passed, that one. Restore proven: blob4123cfb893a1equals HEAD, andgit diff HEADis empty.diststep: every subject is imported fromsrcby relative path.Tests and gates
pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2(whole package, at67fd2c6bee, before the merge oforigin/main8d05456cbe): Test Files 75 passed (75), Tests 1062 passed (1062), lock VERDICT command-exit 0. The merge brought onlyplugin-auth,plugin-email,AGENTS.mdand ascripts/pmfile, none in this package or its dependency closure.pnpm --filter @objectstack/plugin-approvals typecheck: exit 0.check:test-typecheckOK (8 files / 324 errors / 27 signatures held).tsc --listFilesontsconfig.test.jsoncompiles all five edited or new test files.d3ca88e8d3(the final head),node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands. All 67 ran, each exit code captured before any pipe, and all 67 exit 0.check:dual-build-cjs-loadsandcheck:i18nfirst answered 3, PREREQUISITE NOT MET (no workspace dist). After a workspace build (75 tasks, exit 0), both answered 0; they are recorded at that measured 0, not as failures.--ranreconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). eslint over the 7 changed TS files: exit 0, no findings. The local-only families (check:dev-prereqs,check:skill-frame-freshness) are not owed: nopackages/specbuild input or frame file is touched. The full farm is CI's.Acceptance notes
Approved ""(and likewise for the other eight success messages). The action runner's${result.*}grammar substitutes an absent path with an empty string and has no fallback operator. This is the same shape any subject with no resolvable title already toasts (920bc12's own acceptance note); this change adds the readers whose title is withheld to it. It leaks nothing, and the inbox's own inline approve and reject copy falls back to the formatted record id. Not fixed here: it needs either a fallback in the runner's token grammar (objectui) or a server-named, always-present display field (a contract addition).resolveUserAuthzGrants), the per-recipient mechanism the seat declined for the notification body.nameSubjectRecord's inline comment ("withheld until resolveSubjectDisplays narrows it by each reader's FLS") names a condition this PR meets in the inbox. The notification still has no reader to narrow for, so its title stays withheld. The comment is left untouched, as the dispatch ruled (plugin-approvals: an approval request's notification names the record by its raw id (crm_opportunity/<id>), and opening one on an empty slot logs "cannot advance" just before routing to the administrators #23556's remaining half owns it).plugin-securityresolving a real permission set. The pins hold the seam to the field-visibility contract's answers, as the payload pins do.lifecycle-hooks.ts(recordTitleOf) names the record's title to the writer from the engine's update pre-image. Whether that pre-image is narrowed for the writer's field-level security was not measured.Generated by Claude Code