Repository navigation
fix(plugin-audit): @mention notifications reach only mentioned users who can read the record - #24794
Conversation
…ment's record Claude-Session: https://claude.ai/code/session_01BatqV4sRzXmRBizCSYJkMS Co-authored-by: Claude <noreply@anthropic.com>
… across postures Claude-Session: https://claude.ai/code/session_01BatqV4sRzXmRBizCSYJkMS Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BatqV4sRzXmRBizCSYJkMS Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BatqV4sRzXmRBizCSYJkMS Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BatqV4sRzXmRBizCSYJkMS Co-authored-by: Claude <noreply@anthropic.com>
…he old behaviour Claude-Session: https://claude.ai/code/session_01BatqV4sRzXmRBizCSYJkMS Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 10 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 65912646cecf67e118c03759b963b561064201bf && git checkout 65912646cecf67e118c03759b963b561064201bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 46bb5e3e5405fad31ccba6fe571e98b9234aac78 2a048ca9b7fcdee57f89f59ab966e91f3a362274 && git checkout -B drift-repro 46bb5e3e5405fad31ccba6fe571e98b9234aac78 && git merge --no-ff 2a048ca9b7fcdee57f89f59ab966e91f3a362274
node scripts/docs-audit/affected-docs.mjs --json 46bb5e3e5405fad31ccba6fe571e98b9234aac78
|
Fixes #24785
Clause-②: no — the change narrows who a notification reaches; it is a runtime security-boundary change, so it sits on the human floor and its PR stays draft until the maintainer approves
Summary
@mention notification recipients are now filtered by read access to the comment's record. A
collab.mentionnotification carries the comment's preview and a deep link to the record, so it now reaches only a mentioned user who can read that record. The author is never notified.mention-recipient-gate.ts(plugin-audit). Eachmentionsentry resolves to a user id. An email-shaped entry is looked up to its user; an audience expansion (role:/team:/owner_of:) is not a mention. Each user is then judged in their own envelope with no new ACL code. The envelope comes fromresolveUserAuthzGrants, the resolver every request uses. The organization claim is vetted byvetOrganizationClaimunder the posture in force (effectiveTenancyPostureover thetenancyservice). The read is asked ofresolveReadableParentIds, the evaluator every parent-derived read gate uses. So the tenant wall, sharing, RLS and object CRUD decide, the same way they decide when the user opens the deep link.warnline records the user id (when there is one), the reason and the comment id. It never logs the comment text.installAuditWriterstakes the gate as a newadmitMentionRecipientsoption, andAuditPluginwires the real one. A host callinginstallAuditWriterswithout it emits no mention notifications and is told once atwarn.service-messagingis untouched. ItsRecipientResolverserves every topic (approvals, notify nodes and so on), so the narrowing stays on the mention path. The writer hands messaging only already-admitted user ids.sys_notification. A recipient reads their own notification, so no inbox-side read rule could tell a leaked copy from a legitimate one.Tests
All on head
2a048ca9unless noted (the last commit is a comment-only edit to the gate's header; the test runs below are on43bbd4e4, byte-identical code).src/mention-recipient-gate.integration.test.ts(new, 10 cases). It runs a real kernel with ObjectQL, a real SQLite driver,AuditPlugin, the platform'ssys_user/sys_memberdefinitions and the real core resolver. Comments are inserted through the engine as their author, so the comment write gates and theafterInsertwriter run. plugin-security's read layer is played by one middleware that composes the posture's tenant clause and an owner-only rule into the read's WHERE. Messaging is played by anemit()recorder.isolated: a user of another organization gets nothing, in either spelling a mention takes. A fellow member who can read the record is notified, withsourceset to the record (the deep link) and the record's organization. An email of a member resolves to their id. The author gets nothing (by id or email). Audience expansions are no mention.group: a non-member of the record's organization gets nothing; a member is notified.single: a same-organization member that the record's own sharing excludes gets nothing; the same member is notified on a record they can read.{ reason, userId?, commentId }only.audit-writers.test.ts(+3). With no gate composed, the writer emits no mention. With a gate that throws, it emits none. With a gate, it emits only the admitted ids, and the gate receives the expected input. The existing mention unit tests now pass a pass-through gate.audit-hook-object-scope.test.ts: the registration-scope pin passes a pass-through gate.pnpm --filter @objectstack/plugin-audit typecheck && pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=1→Test Files 60 passed (60) · Tests 976 passed (976), exit 0. The new test file was re-run after its final typing edit:Tests 10 passed (10), typecheck exit 0.Ablations (committed fix,
scripts/ablation-replace.mjs, restored byte-identical,git statusclean)const tenantId = claimedOrganization): 4 of the 10 cases go red (theisolatedcross-organization pins and the log pin). Restored blobcd95ead9997d== HEAD.userIds = mentioned): 8 of 10 go red. Restored blobe0ccbab62207== HEAD.End-to-end on a walled boot
This used a scratch copy of
examples/app-showcaseonOS_TENANCY_POSTURE=isolatedwith the organizations package, the real plugin-security and the real messaging inbox. One comment's mentions covered a user outside the record's organization, a fellow member, and the author.43bbd4e4): the outside user's inbox was unchanged (unreadCount0 → 0). The fellow member got onecollab.mentionwithactionUrlset to the record's page (unreadCount0 → 1) and could read the record (200). The author got nothing.dist/, marker proven present, then restored and rebuilt with the marker proven absent): the outside-user assertion fails. That shows the probe discriminates.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 70 families; reconciliation:70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. All exited 0, with the notes below:pnpm check:dts-closureandpnpm check:dual-build-cjs-loadsfirst ran while the full build was still in progress (PREREQUISITE NOT MET). Both were re-run after the build: exit 0.pnpm check:query-options-erasurewent red on the first pass: 3 new untyped option sites in the new test. Those were typed, and the re-run gave exit 0 (test surface: 236 site(s), at the ceiling).node scripts/check-plugin-teardown-shape.mjs --self-testexited 3: this shallow clone cannot reach its pinned positive-control commit. NOT MEASURED here; CI runs it.cross-package-test-inputs,test-source-aliasandnul-byteswere re-run on the final code: exit 0.Acceptance notes
role:/team:/owner_of:entries inmentions. These no longer fan out. The field is documented as "JSON array of the user ids @mentioned".packages/qa/dogfood, which is outside this card's file surface. The walled-boot run above is the manual equivalent.Generated by Claude Code