Skip to content

Commit be90dea

Browse files
os-zhuangclaude
andauthored
fix(plugin-audit,rest)!: sys_comment 的访问权从 thread_id 指向的记录继承 (#4630) (#4758)
* fix(plugin-audit,rest)!: sys_comment 的访问权从 thread_id 指向的记录继承 (#4630) sys_attachment 的可见性由父记录派生,sys_comment 什么都不派生:同一条 记录、同一个用户,两者答案不同 —— 读不到 opportunity 的 rep2 依然能列出 它下面的评论,并且能 POST 一条(连 `"crm_opportunity:"` 这种空 id 的悬空 thread 也 201)。sys_comment 是 public、无 owner 列、父记录藏在 thread_id 字符串里,所以 OWD/sharing 与 RLS 从来没有收窄过它;而 enable.feeds 是 opt-out(spec 默认 true),于是每个应用的每个对象都挂着这条组织级可读可写 的旁路。 AuditPlugin 现在装上 service-storage 给 sys_attachment 装的同一套两件套, 按 thread_id 的 `{object_name}:{record_id}` 解析父记录: - 读侧:find/findOne/count/aggregate 中间件把查询与"调用者真的读得到的 thread"求交(用调用者上下文探测父对象,父对象自己的 OWD/sharing/RLS/ 对象级 CRUD 说了算)。count() 与 find() 同样被过滤,列表 total 不会泄露 被隐藏行的存在。 - 写侧:beforeInsert 要求对父记录可读(能看见的记录就能讨论); beforeUpdate / beforeDelete 要求调用者是评论作者,或对父记录有 EDIT (attachment 的 uploader-or-parent-editor 规则)。author_id 由服务端按 会话盖章,客户端传的值永远不生效 —— 否则"作者可删"本身就可伪造。 全部 fail closed:解析不出记录的 thread_id(悬空空 id、自由文本、指向 sys_comment 自身)写入拒绝、读取排除;过滤器算不出来就 deny-all。拒绝统一 答 403 `RECORD_NOT_ACCESSIBLE` —— 按 ADR-0112 的账本约定,通用权限条件用 标准目录里的码而不是新造同义词;`error.object` 报父记录的对象名,REST 的 映射分支与 attachment/feeds 两个网关同形。 正交且未改动:enable.feeds(FEEDS_DISABLED)仍然只管"这个对象有没有评论", 匿名调用仍然在这一切之前 401。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny * fix(plugin-audit): sharing 槽位查找带上契约类型,不再擦除为 any (#4251 棘轮) `check:slot-lookup` 报 audit-plugin.ts 的擦除计数 1 → 2:文件的祖父豁免只 保存量点位,新点位必须带 slot 的契约类型。 - `ctx.getService<ISharingService>('sharing')` —— 用真的契约接口。 - `CommentSharingLike` 从本地手写接口改为 `Pick<ISharingService, 'canEdit'>`: "只用 canEdit"这个窄面是有意的,再手写一份它的形状不是(PD #12,一份契约 不要方言);`callerContext` 的返回类型同步换成 `SharingExecutionContext`。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05d8a54 commit be90dea

11 files changed

Lines changed: 1713 additions & 1 deletion
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/plugin-audit": major
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(plugin-audit,rest)!: `sys_comment` derives its access from the record its thread names (#4630)
7+
8+
Attachments derive their visibility from the parent record; comments derived
9+
nothing. On the *same* record, with the *same* user, the two answered
10+
differently:
11+
12+
```
13+
user: rep2 (does NOT own and cannot read the opportunity)
14+
GET /api/v1/data/crm_opportunity?$filter=["id","=","1A7n…"] → 200, 0 rows
15+
GET /api/v1/data/sys_attachment?$filter=["parent_id","=","1A7n…"] → 200, 0 rows
16+
GET /api/v1/data/sys_comment?$filter=["thread_id","=","crm_opportunity:1A7n…"]
17+
→ 200, 1 row
18+
POST /api/v1/data/sys_comment {"thread_id":"crm_opportunity:", …} → 201 Created
19+
```
20+
21+
`sys_comment` is public, has no owner column, and hides its parent inside a
22+
string (`thread_id` = `{object_name}:{record_id}`), so neither OWD/sharing nor
23+
RLS ever narrowed it. Because `enable.feeds` is opt-OUT (spec default `true`),
24+
every object in every app carried that org-wide readable, org-wide writable
25+
side-channel — a deployment that carefully authored OWD, sharing rules and RLS
26+
on its records still leaked their discussion.
27+
28+
`AuditPlugin` now installs the same two-part kit `service-storage` installs for
29+
`sys_attachment`, keyed off `thread_id`'s parent:
30+
31+
- **read** — a `find`/`findOne`/`count`/`aggregate` middleware intersects every
32+
query with the threads whose record the caller can actually read (resolved
33+
through the caller-scoped engine, so the parent's own OWD/sharing/RLS/CRUD
34+
decide). `count()` is filtered identically to `find()`, so a list `total`
35+
cannot leak the hidden rows' existence either.
36+
- **write**`beforeInsert` requires READ on the record the thread names;
37+
`beforeUpdate` / `beforeDelete` require the caller to be the comment's AUTHOR
38+
or to hold EDIT on that record. `author_id` is server-stamped from the
39+
session, so a client-supplied value never wins.
40+
41+
Everything fails CLOSED: a `thread_id` that names no record — the dangling
42+
`"crm_opportunity:"` above, a free-form thread, a thread on `sys_comment`
43+
itself — is refused on write and excluded on read, and a filter that cannot be
44+
computed denies all rather than falling open. Refusals answer **403
45+
`RECORD_NOT_ACCESSIBLE`** (the standard error catalog, per ADR-0112 — a generic
46+
permission condition takes a catalogued code rather than a new synonym), with
47+
`error.object` naming the record's object.
48+
49+
**Breaking for deployments that depended on the gap.** Reads that used to
50+
return other people's comments now return fewer rows (or none), and writes that
51+
used to 201 now 403. Specifically:
52+
53+
- Listing `sys_comment` without being able to read the parent record → the row
54+
is gone, not merely unlabelled. Panels that render a thread must be reached by
55+
a principal who can read the record.
56+
- Threads whose `thread_id` is not `{object_name}:{record_id}` are no longer
57+
usable at all: creating one is refused, and existing rows become invisible to
58+
everyone but system context. Migrate free-form threads to a real record
59+
reference (or keep them under a system-context surface).
60+
- Deleting or editing another user's comment now requires EDIT on the record.
61+
Note also that `sys_comment` delete already needed a permission set carrying
62+
`allowDelete` — the `member_default` baseline has none (ADR-0090 D5).
63+
- Posting a comment no longer requires the client to send `author_id` (it is
64+
stamped); a client that sends someone else's is silently corrected rather than
65+
believed.
66+
67+
Orthogonal and unchanged: `enable.feeds` (`FEEDS_DISABLED`) still gates whether
68+
an object has comments at all, and anonymous callers are still refused with 401
69+
before any of this runs.

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,75 @@ describe('AuditPlugin — system table provisioning', () => {
8888
await expect(fireReady()).resolves.toBeUndefined();
8989
});
9090
});
91+
92+
/**
93+
* #4630 — the sys_comment record-level gates are only worth as much as their
94+
* MOUNTING: `comment-access-hooks.test.ts` proves what the hooks decide, this
95+
* proves the plugin actually installs them on a real kernel:ready, on the right
96+
* object, alongside (not instead of) the audit writers. "Who mounts this" is a
97+
* question about the composed runtime, and a gate that silently stops being
98+
* registered fails exactly like a gate that was never written.
99+
*/
100+
describe('AuditPlugin — sys_comment access gates are mounted', () => {
101+
function makeGateEngine() {
102+
const hooks: Array<{ event: string; object?: string; packageId?: string; handler: (ctx: any) => Promise<void> }> = [];
103+
const middlewares: Array<{ object?: string }> = [];
104+
const engine = {
105+
registerHook(event: string, handler: any, options?: { object?: string; packageId?: string }) {
106+
hooks.push({ event, handler, ...options });
107+
},
108+
registerMiddleware(_fn: any, options?: { object?: string }) {
109+
middlewares.push({ ...options });
110+
},
111+
async find() { return [] as unknown[]; },
112+
async findOne() { return null; },
113+
async syncObjectSchema() {},
114+
};
115+
return { engine, hooks, middlewares };
116+
}
117+
118+
it('registers the write hooks + the read middleware on sys_comment at kernel:ready', async () => {
119+
const { engine, hooks, middlewares } = makeGateEngine();
120+
const { ctx, fireReady } = makeCtx(engine);
121+
const plugin = new AuditPlugin();
122+
await plugin.init(ctx);
123+
await plugin.start(ctx);
124+
await fireReady();
125+
126+
const commentHooks = hooks.filter((h) => h.object === 'sys_comment');
127+
for (const event of ['beforeInsert', 'beforeUpdate', 'beforeDelete']) {
128+
expect(commentHooks.some((h) => h.event === event)).toBe(true);
129+
}
130+
expect(middlewares).toContainEqual({ object: 'sys_comment' });
131+
// The audit writers are still installed — the gates are additive.
132+
expect(hooks.some((h) => h.event === 'afterInsert' && !h.object)).toBe(true);
133+
});
134+
135+
it('the mounted beforeInsert actually refuses a comment on an unreadable record', async () => {
136+
const { engine, hooks } = makeGateEngine();
137+
const { ctx, fireReady } = makeCtx(engine);
138+
const plugin = new AuditPlugin();
139+
await plugin.init(ctx);
140+
await plugin.start(ctx);
141+
await fireReady();
142+
143+
// Caller-scoped api that can read nothing — the #4630 rep2 situation.
144+
const hookCtx = {
145+
object: 'sys_comment',
146+
event: 'beforeInsert',
147+
input: {
148+
data: { thread_id: 'crm_opportunity:1A7nlQpfEhWxIaeX', body: 'rep2 should not be here' },
149+
options: { context: { userId: 'rep2' } },
150+
},
151+
session: { userId: 'rep2' },
152+
api: { object: () => ({ findOne: async () => null }) },
153+
};
154+
const insertHooks = hooks.filter((h) => h.object === 'sys_comment' && h.event === 'beforeInsert');
155+
const results = await Promise.allSettled(insertHooks.map((h) => h.handler(hookCtx)));
156+
const denials = results.filter(
157+
(r): r is PromiseRejectedResult => r.status === 'rejected',
158+
);
159+
expect(denials).toHaveLength(1);
160+
expect(denials[0].reason).toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 });
161+
});
162+
});

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import { resolveLocalizationContext } from '@objectstack/core';
5-
import type { IDataEngine } from '@objectstack/spec/contracts';
5+
import type { IDataEngine, ISharingService } from '@objectstack/spec/contracts';
66
import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
77
// `sys_notification` was parked here "until that [ADR-0030] migration lands".
88
// It has landed, so the contribution moved to @objectstack/service-messaging —
@@ -14,6 +14,7 @@ import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
1414
// @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a
1515
// file↔record link belongs with storage, not the compliance ledger).
1616
import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js';
17+
import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js';
1718

1819
/**
1920
* AuditPlugin
@@ -127,6 +128,40 @@ export class AuditPlugin implements Plugin {
127128
};
128129
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale });
129130
ctx.logger.info('AuditPlugin: audit + activity writers installed');
131+
132+
// #4630 — record-level authorization for sys_comment: a comment's access
133+
// derives from the record its `thread_id` names, exactly as an
134+
// attachment's derives from its parent (service-storage's
135+
// installAttachmentAccessHooks / installAttachmentReadVisibility). Both
136+
// halves are needed: the hooks gate writes, the middleware is the only
137+
// seam that filters `count()` (→ list `total`) like `find()`. Orthogonal
138+
// to `enforceFeedsCapability` above, which gates `enable.feeds`, not
139+
// access. The sharing service resolves lazily so plugin order doesn't
140+
// matter; without it the edit checks degrade to parent read visibility.
141+
if (typeof (engine as any).registerHook === 'function') {
142+
installCommentAccessHooks(
143+
engine as any,
144+
() => {
145+
try {
146+
// Typed with the slot's contract (#4251): the gate consults
147+
// `canEdit` only, but it consults the REAL interface.
148+
return ctx.getService<ISharingService>('sharing');
149+
} catch {
150+
return null;
151+
}
152+
},
153+
ctx.logger,
154+
);
155+
if (typeof (engine as any).registerMiddleware === 'function') {
156+
installCommentReadVisibility(engine as any, ctx.logger);
157+
} else {
158+
ctx.logger.warn(
159+
'AuditPlugin: engine has no middleware seam — sys_comment READ visibility NOT installed ' +
160+
'(comments on records the caller cannot read would be listable)',
161+
);
162+
}
163+
ctx.logger.info('AuditPlugin: sys_comment record-level access gates installed');
164+
}
130165
});
131166
}
132167

0 commit comments

Comments
 (0)