Skip to content

Commit c3fb185

Browse files
fix(plugin-audit): a mention notification reaches a mentioned user only when they may read the comment it previews (#26497)
Fixes #26453 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:@提及通知现在要过两道读权限:被提及者既要能读这条评论所在的记录,也要能读这条评论本身。读不到评论的人收不到这条通知。 - **为什么改**:通知里带着评论正文的摘要。能读记录不等于能读评论,因为评论对象有自己的对象权限、租户墙和行级范围。铃铛里的这份副本不能送到评论区本身不给看的人手里。这是安全类卡,细节按维护者裁决不公开。 - **风险与回滚**:如果某个应用给成员的权限集没有授予评论读权限,这些成员以后收不到 @提及通知,这是本修复的预期效果,changeset 里写了管理员该怎么调整。回滚就是 revert 本 PR:只动 `@objectstack/plugin-audit` 一个包,没有数据迁移,也没有 spec 变更。 - **先看**:① `packages/plugins/plugin-audit/src/mention-recipient-gate.ts` 里逐收件人判定循环新增的评论读取那一行(`readableIn(..., COMMENT_OBJECT, commentId)`);② `mention-recipient-gate.integration.test.ts` 里新增的 DAVE 用例和 `sys_comment` 读权限替身;③ `.changeset/26453-mention-recipient-reads-comment.md`。 - **两分钟验证**:先构建依赖闭包 `pnpm --filter '@objectstack/plugin-audit^...' build`,再跑 `pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 src/mention-recipient-gate.integration.test.ts`,应该看到 15/15 通过。 - **需要你做的**:无,席位落地。 ## What changes - **`mention-recipient-gate.ts`**: the gate used to decide each mentioned user on one read, the record the comment is on. It now also runs that user's own read of the comment, by id, through `resolveReadableParentIds`, the same evaluator the comment-reaction create door asks of a comment. The read runs in the user's resolved envelope, with their organization claim vetted exactly as before. When that read does not return the comment, the user is withheld and logged with the new reason `cannot_read_thread`. A comment with no id withholds every candidate (`no_comment_id`). The record read stays in place and runs first, for two reasons: the deep link opens the record, and an engine with no middleware seam has no thread read gate to fold the record question into the comment read. The module header is rewritten so that what it claims is true. The old header said a thread is readable exactly when its record is, and that is wrong, because the comment object carries its own grant. - **`comment-access-hooks.ts`**: `COMMENT_OBJECT` is exported from this internal module so the gate names the object from one source. The package index is unchanged. - **`audit-writers.ts`, `audit-plugin.ts`**: the doc comments now describe the two-read rule. No code changes in either file. - **Changeset**: `patch` on `@objectstack/plugin-audit`. ## Tests `mention-recipient-gate.integration.test.ts` runs a real kernel, ObjectQL, SQLite, `AuditPlugin` (including its comment read gate) and the real grant resolver. - **New stand-in**: plugin-security's object-level read grant on `sys_comment`. It is keyed on a position assigned through `sys_user_position`, which the real resolver reads, and it refuses the way the real layer does: a throw carrying `PERMISSION_DENIED` / 403. A new fellow member, DAVE, can read the open record but does not hold that position. - **New pins**: - a control: the resolver projects the grant to CAROL and not to DAVE, both read the record, and only CAROL reads the comment, while DAVE's read is refused with `code`/`status`; - a recipient who can read the record but not the comment is withheld, by id and by email; - only the recipient who can read both is notified, and the author never is; - the single posture behaves like a walled one; - the withheld-recipient log carries `cannot_read_thread` and `no_comment_id`, and never the comment text. - **Adjusted pin**: the existing direct-call log pin now reads a real comment id. Before, it passed a non-existent one, which the comment read now correctly refuses. Ablation: deleting the comment-read limb turns exactly the 5 new gate pins red. The other 10 stay green, including the stand-in control. The file was then restored, and its blob matches HEAD. <details><summary>Pin, package suite, typecheck (head 88a2b6c)</summary> ```text pnpm --filter @objectstack/plugin-audit exec vitest run --maxWorkers=2 src/mention-recipient-gate.integration.test.ts Test Files 1 passed (1) · Tests 15 passed (15) · VERDICT command-exit 0 (912eeac; file unchanged since) pnpm --filter @objectstack/plugin-audit test Test Files 64 passed (64) · Tests 1028 passed (1028) · VERDICT command-exit 0 (88a2b6c) pnpm --filter @objectstack/plugin-audit typecheck tsc --noEmit · tsc -p tsconfig.scripts.json · check:test-typecheck OK · VERDICT command-exit 0 (88a2b6c) tsc --listFiles: tsconfig.test.json compiles both the gate and its integration test ``` </details> <details><summary>Ablation, engine layer</summary> The test imports its subject through relative `src` paths, so no rebuild was involved. ```text node scripts/ablation-replace.mjs --delete (the comment-read `else if` line of the verdict loop) anchor 1 -> 0 · blob 1ee01f8 -> 47e854c54865 Tests 5 failed | 10 passed (15) the five new gate pins red; the stand-in control green restore: blob == HEAD (1ee01f8) · git diff HEAD empty ``` </details> <details><summary>One-time check in a real composition (throwaway, not committed)</summary> A throwaway dogfood file booted `bootStack` with the real plugin-security, plugin-audit and messaging, org-bound, and drove the gate through the REST layer. All three legs ran at 88a2b6c: - **With the fix**: the gate admitted exactly the recipient who may read both the comment and the record. - **Mutated leg**: the comment-read limb was deleted and plugin-audit rebuilt. `ablation-dist-preflight --absent` showed the limb absent from `dist/`. The gate admitted the record-only recipient too, and that was the one red assertion. The rebuild exited 1 at its DTS stage after writing the JS bundles the suite consumes, so the `dist/` preflight reading, not the build's exit code, certifies this leg. - **Restore leg**: the rebuild exited 0, the limb was present in `dist/` again, and the result was green again. The throwaway file was then deleted, and `git status --porcelain` is empty. </details> ## Acceptance notes - **Behaviour for deployments**: a mentioned user is notified only when the sets they hold grant read on the comment as well as on the record. The changeset tells administrators how to restore notifications for a population that should take part in record discussions: grant comment read in a set that population holds. - **Producer sweep**: the mention writer in `audit-writers.ts` is the only producer that copies `sys_comment` text into a notification. `approval.comment` in plugin-approvals notifies an approval request's own participants about approval-action comments. That is a different object with its own audience, and it is not in scope here. - **Cost**: one extra caller-scoped read per mentioned candidate who passes the record read. - **Timing**: the comment is read from inside the author's write, in `afterInsert`. The engine binds reads there to the write's own unit of work (the ambient transaction store), so the row is visible. A read that cannot see the row withholds, which is the fail-closed direction. The existing CAROL pins exercise this path end to end. - **Not run locally**: - `test:repo` (the vitest `repo` project): it belongs to CI's `Test Core`, and this diff changes no test in that project. - Full dogfood: the title has no `!` and there is no `(narrowing)` arm. - `packages/cli` integration tier: not touched. ## Verification Local subset, run at head `88a2b6cd2d`. The families CI runs are read from this PR's check-runs, not re-run here. <details><summary>Derived gate list: 64 of 64 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 64 commands for the 6 changed paths. Each ran sequentially from the worktree root, with its exit code captured before any pipe. Reconciliation (`--ran`, exit 0): `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED`. - `check:dual-build-cjs-loads` first read exit 1. At that moment a concurrent closure build of mine was rewriting `dist/`, and the finding was a missing CommonJS file of a package this diff does not touch. Re-run on the quiet tree, it exited 0: 112 entry points across 69 packages load. - An earlier run at `428f1f9c20`, before the merge of `main`, had 62 commands at exit 0 and 2 at exit 3 (PREREQUISITE NOT MET: `check:dual-build-cjs-loads` and `check:i18n`). Both are measured green at `88a2b6cd2d`. ```text node scripts/check-adr-0087-registration.mjs --base origin/main 0 node scripts/check-adr-0087-registration.mjs --self-test 0 node scripts/check-changeset-no-major.mjs --base origin/main 0 node scripts/check-ci-filter-parity.mjs 0 node scripts/check-closing-keyword-parity.mjs (+ --self-test) 0 0 node scripts/check-comment-mask-adoption.mjs (+ --self-test) 0 0 node scripts/check-comment-mask-corpus.mjs 0 node scripts/check-dts-emitted.mjs --self-test 0 node scripts/check-empty-changeset.mjs --base origin/main (+ --self-test) 0 0 node scripts/check-issue-citations.mjs 0 node scripts/check-plugin-teardown-shape.mjs (+ --self-test) 0 0 node scripts/check-tenant-audit-census.mjs (+ --self-test) 0 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test 0 node scripts/ci/plan-test-core-shards.mjs --self-test 0 node scripts/ci/prune-turbo-cache.mjs --self-test 0 node scripts/docs-audit/check-affected-docs.mjs 0 node scripts/docs-audit/check-drift-comment.mjs 0 node scripts/pm/release-rehearsal-clone.mjs --self-test 0 node scripts/release-pending-publish.mjs --self-test 0 pnpm --filter @objectstack/spec run check:duration-unit-keys 0 pnpm check:changeset-gate-self-tests · check:closed-ceremony-text 0 0 pnpm check:cross-package-test-inputs · check:dispatcher-error-vocabulary 0 0 pnpm check:doc-authoring · check:driver-memory-census · check:dts-closure 0 0 0 pnpm check:dual-build-cjs-loads (re-run on the quiet tree) 0 pnpm check:durability-log-level · check:engine-double-contract 0 0 pnpm check:error-status-conformance · check:gitlink-declared 0 0 pnpm check:i18n · check:i18n-stale-fill · check:issue-citations 0 0 0 pnpm check:keyed-text-bounds · check:lean-entry-closure 0 0 pnpm check:logger-receiver-detach · check:nul-bytes 0 0 pnpm check:objectql-double-limit · check:org-identifier 0 0 pnpm check:page-declaration-shape · check:platform-object-tenancy-census 0 0 pnpm check:pm-changeset-deadline-census · check:published-files 0 0 pnpm check:query-options-erasure · check:refd-timer-probe 0 0 pnpm check:registry-log-declared · check:rest-log-spy-declared 0 0 pnpm check:slot-lookup · check:sourcemap-no-sources-content 0 0 pnpm check:system-context-census · check:test-source-alias 0 0 pnpm check:tier-file-adoption · check:type-check-coverage 0 0 pnpm check:type-check-debt · check:undeclared-dep-imports 0 0 pnpm check:watch-hint-literal · check:where-matcher 0 0 ``` </details> <details><summary>Narrowed spot readings</summary> ```text require('packages/plugins/plugin-audit/dist/index.js') -> AuditPlugin, installAuditWriters: function exit 0 import('packages/plugins/plugin-audit/dist/index.mjs') -> AuditPlugin: function exit 0 grep -naP control bytes over the six changed files no match ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_012YjcDon6BvR9MN93ZsFq3U)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 00dde01 commit c3fb185

6 files changed

Lines changed: 239 additions & 74 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
plugin-audit: a mention notification reaches a mentioned user only when they may read the comment it previews, as well as the record it links
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `@mention` recipient gate decided each mentioned user on one read: may they open the record the comment is on. The notification also carries a preview of the comment's text, and being able to read a record does not mean being able to read its comments, because the comment object has its own object-level grant, tenant wall and row scope. The gate now also runs each recipient's own read of the comment, by id. It uses the same evaluator as the comment read path (`resolveReadableParentIds`, in the recipient's resolved envelope), and it withholds the notification when that read does not return the comment. Each withheld recipient is logged at `warn` with a new reason: `cannot_read_thread`, or `no_comment_id` when the comment has no id to read it by. The comment text is never logged.
11+
12+
For administrators: a mentioned user is notified only when a permission set they hold grants read on comments. To notify a population that should take part in record discussions, grant comment read in a set it holds.

‎packages/plugins/plugin-audit/src/audit-plugin.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,8 @@ export class AuditPlugin implements Plugin {
423423
return typeof tenancy?.defaultOrgId === 'function' ? tenancy.defaultOrgId() : undefined;
424424
};
425425
// A mention notification previews its comment and links its record, so
426-
// it reaches only a mentioned user who can READ that record — judged in
427-
// that user's own envelope, their organization claim vetted under the
426+
// it reaches only a mentioned user who can READ both — judged in that
427+
// user's own envelope, their organization claim vetted under the
428428
// posture the request path reads (`mention-recipient-gate.ts`).
429429
const getTenancyPosture = () => {
430430
try {

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,10 @@ export interface AuditWriterOptions {
178178
/**
179179
* The `@mention` recipient gate (`mention-recipient-gate.ts`): the mentioned
180180
* users who may be notified about a comment — each resolved to a user id,
181-
* minus the author, minus everyone who cannot READ the comment's record.
182-
* AuditPlugin wires the real gate. Absent → no mention notification is
183-
* emitted at all (fail closed, said once): the writer cannot tell on its own
184-
* who may see the record a notification would preview.
181+
* minus the author, minus everyone who cannot READ the comment's record or
182+
* the comment itself. AuditPlugin wires the real gate. Absent → no mention
183+
* notification is emitted at all (fail closed, said once): the writer cannot
184+
* tell on its own who may see the comment a notification would preview.
185185
*/
186186
admitMentionRecipients?(input: MentionGateInput): Promise<string[]>;
187187
}
@@ -2529,7 +2529,7 @@ export function installAuditWriters(
25292529
/**
25302530
* M10.8: Dedicated hook on `sys_comment` afterInsert that parses the
25312531
* `mentions` JSON field and writes one sys_notification per mentioned
2532-
* user who can read the comment's record (`admitMentionRecipients`).
2532+
* user who can read the comment and its record (`admitMentionRecipients`).
25332533
* Lives outside `writeAudit` because sys_comment is in
25342534
* SKIP_OBJECTS (we don't want audit/activity rows for comments —
25352535
* those have their own first-class feed).
@@ -2565,9 +2565,9 @@ export function installAuditWriters(
25652565
const commentId = row.id != null ? String(row.id) : null;
25662566

25672567
// The notification is a copy of the comment (its preview) and a link to
2568-
// the record, so it reaches only a mentioned user who can READ that
2569-
// record — never a raw id or email from the client-written list. The
2570-
// author is dropped there too.
2568+
// the record, so it reaches only a mentioned user who can READ both the
2569+
// comment and that record — never a raw id or email from the
2570+
// client-written list. The author is dropped there too.
25712571
if (!opts.admitMentionRecipients) {
25722572
if (!warnedNoMentionGate) {
25732573
warnedNoMentionGate = true;

‎packages/plugins/plugin-audit/src/comment-access-hooks.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,8 @@ const READ_OPS = new Set(['find', 'findOne', 'count', 'aggregate']);
249249
* `{ id: '__deny_all__' }` read-filter deny). */
250250
const READ_DENY_ALL = { id: '__comment_thread_denied__' } as const;
251251

252-
/** The object a reaction's access derives from. */
253-
const COMMENT_OBJECT = 'sys_comment';
252+
/** The object a reaction's access derives from, and the one a mention notification previews. */
253+
export const COMMENT_OBJECT = 'sys_comment';
254254

255255
/**
256256
* A comment's emoji reactions — one row per (comment, emoji, user), the

‎packages/plugins/plugin-audit/src/mention-recipient-gate.integration.test.ts‎

Lines changed: 139 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
/**
44
* A `collab.mention` notification reaches a mentioned user only when that user
5-
* can READ the record the comment is about (`mention-recipient-gate.ts`).
5+
* can READ both the comment it previews and the record it links
6+
* (`mention-recipient-gate.ts`).
67
*
78
* ## What is real
89
*
910
* The kernel, ObjectQL, a real SQLite driver, `AuditPlugin` (its comment write
10-
* gates and its mention writer, wired with the real recipient gate), the
11-
* identity rows (`sys_user`, `sys_member` — the platform's own object
12-
* definitions) and the grant resolution over them (`resolveUserAuthzGrants`,
13-
* `vetOrganizationClaim`, `resolveReadableParentIds` from `@objectstack/core`).
14-
* Every comment is inserted through the engine as its author, so the comment
15-
* write gates run and the mention writer fires from the real `afterInsert`.
11+
* gates, its comment read gate and its mention writer, wired with the real
12+
* recipient gate), the identity rows (`sys_user`, `sys_member` — the
13+
* platform's own object definitions) and the grant resolution over them
14+
* (`resolveUserAuthzGrants`, `vetOrganizationClaim`, `resolveReadableParentIds`
15+
* from `@objectstack/core`). Every comment is inserted through the engine as
16+
* its author, so the comment write gates run and the mention writer fires from
17+
* the real `afterInsert`.
1618
*
17-
* ## The two stand-ins
19+
* ## The three stand-ins
1820
*
1921
* - plugin-security's read enforcement on the record object, played by ONE
2022
* engine middleware: the ADR-0093 tenant wall (`isolated`: the caller's
@@ -24,19 +26,27 @@
2426
* plugin-security's to pin; what is pinned HERE is that the gate asks the
2527
* read in the mentioned user's own envelope, with their organization claim
2628
* vetted — the stand-in admits whatever envelope it is handed.
29+
* - plugin-security's object-level read grant on `sys_comment`, played by a
30+
* second middleware: a caller whose envelope holds the `comment_reader`
31+
* position may read comments, and any other caller's read is refused the way
32+
* the real layer refuses it — a throw. It stands in for "the set this
33+
* position distributes grants read on comments". ALICE, CAROL and BOB hold
34+
* the position, each in their own organization; DAVE, a member of ORG_A like
35+
* CAROL, does not — so he reads the open record and not its comments.
2736
* - the `messaging` service, played by a recorder of `emit()` calls (the
2837
* inbox materialisation is service-messaging's to pin).
2938
*
3039
* plugin-security also owns the two grant tables the resolver reads beside
3140
* `sys_member` (`sys_user_position`, `sys_user_permission_set`). They are
32-
* registered here with the columns the resolver reads and left EMPTY — no
33-
* user holds a position or a permission set, so membership alone decides the
41+
* registered here with the columns the resolver reads. `sys_user_position`
42+
* carries the `comment_reader` assignments above and nothing else, and
43+
* `sys_user_permission_set` is left EMPTY, so membership alone decides the
3444
* wall. Unregistered, the resolver refuses (an unreadable authorization store
3545
* is an outage, not a denial) and the gate withholds every notification.
3646
*/
3747

3848
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
39-
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
49+
import { ObjectKernel, resolveUserAuthzGrants, type Plugin, type PluginContext } from '@objectstack/core';
4050
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
4151
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
4252
import { SysMember, SysUser } from '@objectstack/platform-objects/identity';
@@ -56,6 +66,10 @@ const ALICE = { id: 'u_alice', email: 'alice@a.test' };
5666
const CAROL = { id: 'u_carol', email: 'carol@a.test' };
5767
/** A member of ORG_B only. */
5868
const BOB = { id: 'u_bob', email: 'bob@b.test' };
69+
/** A fellow member of ORG_A who reads every non-confidential task there, and holds no read on comments. */
70+
const DAVE = { id: 'u_dave', email: 'dave@a.test' };
71+
/** The position whose holders the comment stand-in lets read comments — assigned through `sys_user_position`. */
72+
const COMMENT_READER_POSITION = 'comment_reader';
5973

6074
type Posture = 'single' | 'isolated' | 'group';
6175
const tenancy: { posture: Posture } = { posture: 'isolated' };
@@ -79,6 +93,19 @@ function enforcement(context: any): Record<string, unknown> {
7993
return { $and: clauses };
8094
}
8195

96+
/**
97+
* plugin-security's object-level read grant on `sys_comment`, as the module
98+
* header sets out: read off the caller's envelope, refused by a throw.
99+
*/
100+
function assertReadsComments(context: any): void {
101+
const positions: unknown = context?.positions;
102+
if (Array.isArray(positions) && positions.includes(COMMENT_READER_POSITION)) return;
103+
const err: any = new Error('stand-in: no read grant on sys_comment');
104+
err.code = 'PERMISSION_DENIED';
105+
err.status = 403;
106+
throw err;
107+
}
108+
82109
const emits: Array<Record<string, any>> = [];
83110

84111
function standIns(): Plugin {
@@ -106,29 +133,39 @@ function standIns(): Plugin {
106133
const authorContext = () => ({
107134
userId: ALICE.id,
108135
tenantId: ORG_A,
109-
positions: ['org_member'],
136+
positions: ['org_member', COMMENT_READER_POSITION],
110137
permissions: [],
111138
accessible_org_ids: [ORG_A],
112139
});
113140

114-
describe('@mention notifications reach only mentioned users who can read the record', () => {
141+
describe('@mention notifications reach only mentioned users who can read the comment and its record', () => {
115142
let kernel: ObjectKernel;
116143
let engine: ObjectQL;
117144
let openTask: string;
118145
let confidentialTask: string;
119146
let seq = 0;
120147

121-
/** Post a comment on `taskId` as ALICE, mentioning `mentions`; the mention emits it produced. */
122-
const comment = async (taskId: string, mentions: unknown[]) => {
123-
emits.length = 0;
148+
/** Post a comment on `taskId` as ALICE, mentioning `mentions`; the stored row. */
149+
const post = async (taskId: string, mentions: unknown[]): Promise<Record<string, any>> => {
124150
seq += 1;
125-
await engine.insert(
151+
return engine.insert(
126152
'sys_comment',
127153
{ thread_id: `${TASK}:${taskId}`, body: `note ${seq}`, mentions: JSON.stringify(mentions) },
128154
{ context: authorContext() },
129155
);
156+
};
157+
/** Post a comment on `taskId` as ALICE, mentioning `mentions`; the mention emits it produced. */
158+
const comment = async (taskId: string, mentions: unknown[]) => {
159+
emits.length = 0;
160+
await post(taskId, mentions);
130161
return emits.filter((e) => e.topic === 'collab.mention');
131162
};
163+
/** `user`'s own read of the comment `commentId`, in the envelope the request path gives them. */
164+
const readCommentAs = (user: { id: string }, positions: string[], commentId: string) =>
165+
engine.find('sys_comment', {
166+
where: { id: commentId },
167+
context: { userId: user.id, tenantId: ORG_A, positions, permissions: [], accessible_org_ids: [ORG_A] },
168+
});
132169
const audiences = (events: Array<Record<string, any>>) => events.map((e) => e.audience);
133170

134171
beforeAll(async () => {
@@ -180,13 +217,24 @@ describe('@mention notifications reach only mentioned users who can read the rec
180217
}
181218
return next();
182219
}, { object: TASK });
220+
engine.registerMiddleware(async (op: any, next: () => Promise<void>) => {
221+
if (READ_OPS.has(op.operation) && op.ast && !op.context?.isSystem) assertReadsComments(op.context);
222+
return next();
223+
}, { object: 'sys_comment' });
183224

184-
for (const u of [ALICE, CAROL, BOB]) {
225+
for (const u of [ALICE, CAROL, BOB, DAVE]) {
185226
await engine.insert('sys_user', { id: u.id, name: u.id, email: u.email }, { context: SYS });
186227
}
187-
for (const [user, org] of [[ALICE, ORG_A], [CAROL, ORG_A], [BOB, ORG_B]] as const) {
228+
for (const [user, org] of [[ALICE, ORG_A], [CAROL, ORG_A], [BOB, ORG_B], [DAVE, ORG_A]] as const) {
188229
await engine.insert('sys_member', { user_id: user.id, organization_id: org, role: 'member' }, { context: SYS });
189230
}
231+
for (const [user, org] of [[ALICE, ORG_A], [CAROL, ORG_A], [BOB, ORG_B]] as const) {
232+
await engine.insert(
233+
'sys_user_position',
234+
{ user_id: user.id, organization_id: org, position: COMMENT_READER_POSITION },
235+
{ context: SYS },
236+
);
237+
}
190238
openTask = (await engine.insert(TASK, { name: 'open', organization_id: ORG_A, owner_id: ALICE.id, confidential: false }, { context: SYS })).id;
191239
confidentialTask = (
192240
await engine.insert(TASK, { name: 'confidential', organization_id: ORG_A, owner_id: ALICE.id, confidential: true }, { context: SYS })
@@ -257,20 +305,77 @@ describe('@mention notifications reach only mentioned users who can read the rec
257305
});
258306
});
259307

260-
it('logs each withheld recipient with the user id and the reason, never the comment text', async () => {
261-
const warned: Array<{ message: string; meta?: Record<string, unknown> }> = [];
262-
const admitted = await admitMentionRecipients(
263-
{
264-
engine: engine as any,
265-
getTenancyPosture: () => 'isolated',
266-
logger: { warn: (message, meta) => warned.push({ message, meta }) },
267-
},
268-
{ mentions: [BOB.id, CAROL.id, 'nobody@nowhere.test'], threadId: `${TASK}:${openTask}`, actorId: ALICE.id, organizationId: ORG_A, commentId: 'c1' },
269-
);
270-
expect(admitted).toEqual([CAROL.id]);
271-
expect(warned.map((w) => w.meta)).toEqual([
272-
{ reason: 'unknown_email', commentId: 'c1' },
273-
{ reason: 'cannot_read_source', userId: BOB.id, commentId: 'c1' },
274-
]);
308+
describe('the comment itself must be readable, not only its record', () => {
309+
it('control: the resolver projects the comment grant to CAROL and not to DAVE; both read the record, only CAROL its comment', async () => {
310+
const carol = await resolveUserAuthzGrants(engine, CAROL.id, { tenantId: ORG_A });
311+
const dave = await resolveUserAuthzGrants(engine, DAVE.id, { tenantId: ORG_A });
312+
expect(carol.positions).toContain(COMMENT_READER_POSITION);
313+
expect(dave.positions).not.toContain(COMMENT_READER_POSITION);
314+
315+
const asDave = await engine.find(TASK, { where: { id: openTask }, context: { userId: DAVE.id, tenantId: ORG_A } });
316+
expect(asDave.map((r: any) => r.id)).toEqual([openTask]);
317+
318+
const row = await post(openTask, []);
319+
expect((await readCommentAs(CAROL, carol.positions, String(row.id))).map((r: any) => r.id)).toEqual([row.id]);
320+
await expect(readCommentAs(DAVE, dave.positions, String(row.id))).rejects.toMatchObject({
321+
code: 'PERMISSION_DENIED',
322+
status: 403,
323+
});
324+
});
325+
326+
it('withholds the notification from a mentioned user who may read the record but not the comment, by id or by email', async () => {
327+
expect(audiences(await comment(openTask, [DAVE.id]))).toEqual([]);
328+
expect(audiences(await comment(openTask, [DAVE.email]))).toEqual([]);
329+
});
330+
331+
it('notifies the mentioned user who may read both, and never the author', async () => {
332+
const events = await comment(openTask, [DAVE.id, CAROL.id, ALICE.id]);
333+
expect(audiences(events)).toEqual([[CAROL.id]]);
334+
expect(events[0]).toMatchObject({ source: { object: TASK, id: openTask }, payload: { body: `note ${seq}` } });
335+
});
336+
337+
it('holds under the single posture as under a wall', async () => {
338+
tenancy.posture = 'single';
339+
expect(audiences(await comment(openTask, [DAVE.id, DAVE.email, CAROL.id]))).toEqual([[CAROL.id]]);
340+
});
341+
});
342+
343+
describe('the withheld-recipient log', () => {
344+
const gate = (warned: Array<{ message: string; meta?: Record<string, unknown> }>) => ({
345+
engine: engine as any,
346+
getTenancyPosture: () => 'isolated' as const,
347+
logger: { warn: (message: string, meta?: Record<string, unknown>) => warned.push({ message, meta }) },
348+
});
349+
350+
it('logs each withheld recipient with the user id and the reason, never the comment text', async () => {
351+
const row = await post(openTask, []);
352+
const commentId = String(row.id);
353+
const warned: Array<{ message: string; meta?: Record<string, unknown> }> = [];
354+
const admitted = await admitMentionRecipients(gate(warned), {
355+
mentions: [BOB.id, DAVE.id, CAROL.id, 'nobody@nowhere.test'],
356+
threadId: `${TASK}:${openTask}`,
357+
actorId: ALICE.id,
358+
organizationId: ORG_A,
359+
commentId,
360+
});
361+
expect(admitted).toEqual([CAROL.id]);
362+
expect(warned.map((w) => w.meta)).toEqual([
363+
{ reason: 'unknown_email', commentId },
364+
{ reason: 'cannot_read_source', userId: BOB.id, commentId },
365+
{ reason: 'cannot_read_thread', userId: DAVE.id, commentId },
366+
]);
367+
expect(JSON.stringify(warned)).not.toContain(String(row.body));
368+
});
369+
370+
it('withholds every recipient when the comment cannot be read back by its id', async () => {
371+
const warned: Array<{ message: string; meta?: Record<string, unknown> }> = [];
372+
const input = { mentions: [CAROL.id], threadId: `${TASK}:${openTask}`, actorId: ALICE.id, organizationId: ORG_A };
373+
expect(await admitMentionRecipients(gate(warned), input)).toEqual([]);
374+
expect(await admitMentionRecipients(gate(warned), { ...input, commentId: 'cmt_never_written' })).toEqual([]);
375+
expect(warned.map((w) => w.meta)).toEqual([
376+
{ reason: 'no_comment_id', userId: CAROL.id },
377+
{ reason: 'cannot_read_thread', userId: CAROL.id, commentId: 'cmt_never_written' },
378+
]);
379+
});
275380
});
276381
});

0 commit comments

Comments
 (0)