Skip to content

Commit 846ed1f

Browse files
baozhoutaoclaude
andauthored
fix(security): controlled_by_parent 折入主档的归属与共享授权 (#5386) (#5816)
主档可达性此前只走 `computeRlsFilter`(租户 Layer 0 + RLS 策略),归属与 `sys_record_share` 授权由 plugin-sharing 贡献,而它对非 `private` 有效共享模型 返回 null —— `controlled_by_parent` 恰好映射为 public,两半从未相遇。主档没写 RLS 的应用因此拿到不受限的主档 id 集,声明的收窄什么也没收窄;写这半更甚,主档 写 RLS 为空时整段行检查被跳过。 读:`computeControlledByParentFilter` 把主档读 RLS 与 `resolveSharingReadFilter` (`getReadFilter` 已在用的 OWD/共享半边)AND 起来再解析主档 id 集。 写:`assertControlledByParentWrite` 无条件追问 plugin-sharing 的单记录写闸 `canEdit`。两侧解析失败一律 fail closed。 v1 单层语义不变,未装 plugin-sharing 的部署行为不变。 Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a40b7a commit 846ed1f

3 files changed

Lines changed: 529 additions & 14 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): `controlled_by_parent` 现在真的跟随主档访问 —— 折入主档的归属与共享授权 (#5386)
6+
7+
**这是一次安全收紧。** 升级后,此前被越权看到 / 写到的明细行会读不到、写不了 —— 那正是
8+
声明本来就要求的边界。
9+
10+
ADR-0055 的 `controlled_by_parent` 对作者的承诺是「子记录跟随父记录的访问」。实现只兑现了
11+
一半:派生用的主档 id 集来自 `computeRlsFilter(master, 'find')`,即只有 Layer 0(租户)与
12+
Layer 1(`rowLevelSecurity` 策略)。归属(owner scope)与 `sys_record_share` 授权由**另一个
13+
插件** `plugin-sharing``buildReadFilter` 贡献,而它对「有效共享模型不是 `private`」的对象
14+
返回 `null` —— `controlled_by_parent` 在那边恰好映射为 `public`。于是记录级访问的两半在派生
15+
对象上从未相遇。
16+
17+
后果比文档里那句「sharing grants 未折入」读起来严重得多:
18+
19+
- 主档上**没有写任何 `rowLevelSecurity`** 的应用,得到的是一个**不受限的主档 id 集**,派生
20+
过滤器等于什么都没收窄 —— 只要持有对象级 read,全部明细行可读。行项目类对象(报价行、
21+
发票行)是这个形状的常客,而它们携带逐行定价与折扣。
22+
- 在主档上补写 RLS 也不是绕法:RLS 与 sharing 过滤器是 **AND**,补写会连同被共享进来的行
23+
一起切掉。
24+
- 写这半有同样的洞,而且是从另一侧来的:`assertControlledByParentWrite` 只在主档的写 RLS
25+
编译出非空过滤器时才检查主档行,主档没写 RLS 时**整段跳过** —— 持有 `allowEdit` 的调用者
26+
可以改自己根本看不到的父记录下的明细。
27+
28+
**修复**:主档可达性改走与「直接读 / 直接写主档」完全相同的路径,复用既有合成点,不在
29+
plugin-security 里重刻一份 sharing 语义。
30+
31+
- 读:`computeControlledByParentFilter` 现在把主档的读 RLS 与 `resolveSharingReadFilter`
32+
(`getReadFilter` 已经在用的那个 OWD/共享半边)AND 起来再解析主档 id 集。哪一半生效由
33+
**主档自己的有效共享模型**决定,因此派生出的可见集与直接 find 主档逐点一致。
34+
- 写:`assertControlledByParentWrite` 在原有的 CRUD `update` + 写 RLS 之外,**无条件**追问
35+
plugin-sharing 的单记录写闸 `canEdit`(归属按写深度放宽、`edit` 级共享、
36+
`modifyAllRecords` 旁路)—— 无条件,正因为写 RLS 那一半在常见情形下会被整段跳过。
37+
- 两侧解析失败一律**fail closed**(主档 id 集为空 / 拒绝写),而不是悄悄放宽回全员可见。
38+
39+
未变更的部分:v1**单层**语义 —— 主档自身的 `controlled_by_parent` 仍不递归下钻;没有装
40+
`plugin-sharing` 的部署行为不变(那种部署里主档本身也没有归属与共享可言,派生集依旧与直接
41+
读主档相等);`read` 级共享仍然只开读不开写,与直接访问主档的逐动词答案一致。
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#5386] `controlled_by_parent` (ADR-0055) must resolve the MASTER's
4+
// accessibility through the SAME path a direct read / write of that master
5+
// takes — owner scope and `sys_record_share` grants folded in, not the master's
6+
// RLS policies alone.
7+
//
8+
// ## What was measured before this suite existed
9+
//
10+
// The derivation ran `computeRlsFilter(master, 'find')` and nothing else, under
11+
// a system context. Owner scope and record shares are contributed by a SIBLING
12+
// plugin (`plugin-sharing`), whose `buildReadFilter` returns `null` for any
13+
// object whose effective sharing model is not `private` — and
14+
// `controlled_by_parent` maps to `public` there. So the two halves of
15+
// record-level access never met on a derived object:
16+
//
17+
// • an app that authors NO `rowLevelSecurity` on the master got an
18+
// UNRESTRICTED master id set, i.e. the declared narrowing narrowed nothing
19+
// and every detail row was readable by any holder of object-level read;
20+
// • authoring RLS on the master was not a workaround, because RLS is ANDed
21+
// with the sharing filter rather than OR-ed into it, so it also cut off the
22+
// rows a grant had shared in;
23+
// • the write half had the same hole from the other side — its master row
24+
// check was SKIPPED WHOLE when the master's write RLS compiled to `null`.
25+
//
26+
// ## Why one fixture drives both faces
27+
//
28+
// The read filter and the write assertion are two implementation faces of ONE
29+
// contract ("a child follows its parent's access"). A suite that exercised them
30+
// on separate fixtures could not see them disagree, which is the failure this
31+
// family produces: a third, quieter answer where one face allows what the other
32+
// refuses. Every case below runs on the SAME three-account fixture — a master
33+
// owned by someone else and shared to the caller, a master owned by someone
34+
// else and NOT shared (the excluded row, without which "consistent" would prove
35+
// nothing), and a master the caller owns.
36+
37+
import { describe, it, expect, vi } from 'vitest';
38+
import { SecurityPlugin } from './security-plugin.js';
39+
import { SharingService, type SharingEngine } from '@objectstack/plugin-sharing';
40+
import { matchesFilterCondition } from '@objectstack/formula';
41+
import type { PermissionSet } from '@objectstack/spec/security';
42+
43+
const REP = 'usr_rep';
44+
const OTHER = 'usr_other';
45+
46+
/** The MASTER — owner-scoped by OWD (`private`), with no authored RLS at all. */
47+
const ACCOUNT_SCHEMA = {
48+
name: 'crm_account',
49+
sharingModel: 'private',
50+
fields: {
51+
id: { name: 'id', type: 'text' },
52+
name: { name: 'name', type: 'text' },
53+
owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' },
54+
},
55+
};
56+
57+
/** The DETAIL — access derived from `crm_account` through the master_detail FK. */
58+
const CONTACT_SCHEMA = {
59+
name: 'crm_contact',
60+
sharingModel: 'controlled_by_parent',
61+
fields: {
62+
id: { name: 'id', type: 'text' },
63+
name: { name: 'name', type: 'text' },
64+
account: { name: 'account', type: 'master_detail', required: true, reference: 'crm_account' },
65+
},
66+
};
67+
68+
const SHARE_SCHEMA = {
69+
name: 'sys_record_share',
70+
isSystem: true,
71+
fields: {
72+
id: { name: 'id', type: 'text' },
73+
object_name: { name: 'object_name', type: 'text' },
74+
record_id: { name: 'record_id', type: 'text' },
75+
recipient_type: { name: 'recipient_type', type: 'text' },
76+
recipient_id: { name: 'recipient_id', type: 'text' },
77+
access_level: { name: 'access_level', type: 'text' },
78+
owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' },
79+
},
80+
};
81+
82+
/**
83+
* The app's permission set: full CRUD on both objects (so requests reach the
84+
* record layer instead of being refused by RBAC) and — deliberately — NO
85+
* `rowLevelSecurity` anywhere. That is the shape the issue measured: the app
86+
* expresses its record boundary entirely through OWD + sharing, and expects
87+
* `controlled_by_parent` to follow it.
88+
*/
89+
const REP_SET: PermissionSet = {
90+
name: 'crm_rep',
91+
label: 'CRM Rep',
92+
objects: {
93+
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
94+
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
95+
},
96+
} as unknown as PermissionSet;
97+
98+
type Row = Record<string, unknown>;
99+
100+
/**
101+
* In-memory store standing in for the engine's READ surface only — `find`,
102+
* `findOne`, `getSchema`. It declares NO write verb: nothing under test writes
103+
* through it, and a double that does not have a verb cannot be looser than the
104+
* engine on that verb (the `check:engine-double-contract` family, #4434/#5480).
105+
* Filtering runs through `matchesFilterCondition`, the same evaluator the
106+
* security plugin itself uses, so a filter this suite asserts on is a filter
107+
* that was really applied rather than one merely inspected.
108+
*/
109+
function makeStore(rows: Record<string, Row[]>) {
110+
const schemas: Record<string, unknown> = {
111+
crm_account: ACCOUNT_SCHEMA,
112+
crm_contact: CONTACT_SCHEMA,
113+
sys_record_share: SHARE_SCHEMA,
114+
};
115+
return {
116+
rows,
117+
getSchema: (object: string) => schemas[object],
118+
find: vi.fn(async (object: string, options: any = {}) => {
119+
const all = rows[object] ?? [];
120+
const hits = all.filter((r) => matchesFilterCondition(r, options?.where ?? null));
121+
return typeof options?.limit === 'number' ? hits.slice(0, options.limit) : hits;
122+
}),
123+
findOne: vi.fn(async (object: string, options: any = {}) => {
124+
const all = rows[object] ?? [];
125+
return all.find((r) => matchesFilterCondition(r, options?.where ?? null)) ?? null;
126+
}),
127+
};
128+
}
129+
130+
/** The fixture rows — identical for every case; only the grant level varies. */
131+
function fixtureRows(shareLevel: 'read' | 'edit' | null): Record<string, Row[]> {
132+
return {
133+
crm_account: [
134+
{ id: 'acct_us', name: 'US Corp', owner_id: OTHER }, // shared to the rep
135+
{ id: 'acct_eu', name: 'EU Corp', owner_id: OTHER }, // NOT shared — the excluded row
136+
{ id: 'acct_own', name: 'Own Corp', owner_id: REP }, // the rep's own
137+
],
138+
crm_contact: [
139+
{ id: 'ct_us', name: 'US contact', account: 'acct_us' },
140+
{ id: 'ct_eu', name: 'EU contact', account: 'acct_eu' },
141+
{ id: 'ct_own', name: 'Own contact', account: 'acct_own' },
142+
],
143+
sys_record_share: shareLevel
144+
? [
145+
{
146+
id: 'shr_1',
147+
object_name: 'crm_account',
148+
record_id: 'acct_us',
149+
recipient_type: 'user',
150+
recipient_id: REP,
151+
access_level: shareLevel,
152+
},
153+
]
154+
: [],
155+
};
156+
}
157+
158+
interface BootOptions {
159+
/** The single grant's level, or `null` for a fixture with no grant at all. */
160+
shareLevel?: 'read' | 'edit' | null;
161+
/** `'none'` boots a deployment WITHOUT plugin-sharing; `'throws'` a broken one. */
162+
sharing?: 'real' | 'none' | 'throws';
163+
}
164+
165+
async function boot(options: BootOptions = {}) {
166+
const shareLevel = options.shareLevel === undefined ? 'edit' : options.shareLevel;
167+
const store = makeStore(fixtureRows(shareLevel));
168+
169+
let middleware: any;
170+
const ql = {
171+
registerMiddleware: (mw: any) => {
172+
if (!middleware) middleware = mw;
173+
},
174+
getSchema: store.getSchema,
175+
find: store.find,
176+
findOne: store.findOne,
177+
};
178+
179+
const services: Record<string, unknown> = {
180+
manifest: { register: vi.fn() },
181+
objectql: ql,
182+
metadata: { get: async (n: string) => store.getSchema(n), list: async () => [REP_SET] },
183+
};
184+
if ((options.sharing ?? 'real') === 'real') {
185+
// The REAL sharing service over the same store — the point of the fix is
186+
// that the derivation reuses this exact producer instead of re-deriving
187+
// owner/share semantics inside plugin-security.
188+
services.sharing = new SharingService({ engine: store as unknown as SharingEngine });
189+
} else if (options.sharing === 'throws') {
190+
services.sharing = {
191+
buildReadFilter: async () => {
192+
throw new Error('sharing store unreachable');
193+
},
194+
canEdit: async () => {
195+
throw new Error('sharing store unreachable');
196+
},
197+
};
198+
}
199+
200+
const ctx: any = {
201+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
202+
registerService: vi.fn(),
203+
getService: (name: string) => {
204+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
205+
return services[name];
206+
},
207+
};
208+
const plugin = new SecurityPlugin({
209+
defaultPermissionSets: [REP_SET],
210+
fallbackPermissionSet: 'crm_rep',
211+
});
212+
await plugin.init(ctx);
213+
await plugin.start(ctx);
214+
215+
const repContext = () => ({ userId: REP, tenantId: 'org-1', positions: [], permissions: [] });
216+
217+
/** The READ face: run the middleware, then apply the filter it injected. */
218+
const visibleContacts = async (): Promise<string[]> => {
219+
const opCtx: any = {
220+
object: 'crm_contact',
221+
operation: 'find',
222+
ast: {},
223+
options: {},
224+
context: repContext(),
225+
};
226+
await middleware(opCtx, async () => {});
227+
return (store.rows.crm_contact ?? [])
228+
.filter((r) => matchesFilterCondition(r, opCtx.ast.where ?? null))
229+
.map((r) => String(r.id));
230+
};
231+
232+
/** The WRITE face: a by-id update of one detail row. Resolves or throws. */
233+
const updateContact = async (id: string): Promise<void> => {
234+
const opCtx: any = {
235+
object: 'crm_contact',
236+
operation: 'update',
237+
data: { id, name: 'renamed' },
238+
options: { where: { id } },
239+
context: repContext(),
240+
};
241+
await middleware(opCtx, async () => {});
242+
};
243+
244+
const writableContacts = async (): Promise<string[]> => {
245+
const out: string[] = [];
246+
for (const row of store.rows.crm_contact ?? []) {
247+
try {
248+
await updateContact(String(row.id));
249+
out.push(String(row.id));
250+
} catch {
251+
/* denied */
252+
}
253+
}
254+
return out;
255+
};
256+
257+
return { store, ctx, visibleContacts, updateContact, writableContacts };
258+
}
259+
260+
describe('[#5386] controlled_by_parent folds the master\'s ownership and share grants in', () => {
261+
// ── READ face ────────────────────────────────────────────────────────────
262+
it('READ: only children of masters the caller can actually read are visible', async () => {
263+
const h = await boot({ shareLevel: 'edit' });
264+
// acct_own is reachable by OWNERSHIP, acct_us by the single GRANT,
265+
// acct_eu by neither — so its contact must not appear.
266+
expect(await h.visibleContacts()).toEqual(['ct_us', 'ct_own']);
267+
});
268+
269+
it('READ: with no grant at all, only the caller-owned master\'s children survive', async () => {
270+
const h = await boot({ shareLevel: null });
271+
expect(await h.visibleContacts()).toEqual(['ct_own']);
272+
});
273+
274+
it('READ: the derived master id set equals what a direct find of the master returns', async () => {
275+
const h = await boot({ shareLevel: 'edit' });
276+
// The master half, resolved exactly as a direct read would: owner-match
277+
// OR-ed with the caller's grants. This is the set the child filter must
278+
// quantify over — asserted here so a future change that widens the derived
279+
// set without widening the master's own read is caught at the seam.
280+
const sharing = new SharingService({ engine: h.store as unknown as SharingEngine });
281+
const masterFilter = await sharing.buildReadFilter('crm_account', {
282+
userId: REP,
283+
tenantId: 'org-1',
284+
} as any);
285+
const directlyReadable = (h.store.rows.crm_account ?? [])
286+
.filter((r) => matchesFilterCondition(r, masterFilter as any))
287+
.map((r) => String(r.id));
288+
expect(directlyReadable).toEqual(['acct_us', 'acct_own']);
289+
});
290+
291+
// ── WRITE face ───────────────────────────────────────────────────────────
292+
it('WRITE: a by-id update of a child under an unreachable master is denied', async () => {
293+
const h = await boot({ shareLevel: 'edit' });
294+
await expect(h.updateContact('ct_eu')).rejects.toThrow(/requires edit access to its master/);
295+
});
296+
297+
it('WRITE: children of an owned master and of an edit-shared master stay writable', async () => {
298+
const h = await boot({ shareLevel: 'edit' });
299+
await expect(h.updateContact('ct_own')).resolves.toBeUndefined();
300+
await expect(h.updateContact('ct_us')).resolves.toBeUndefined();
301+
});
302+
303+
// ── the invariant the two faces owe each other ───────────────────────────
304+
it('INVARIANT: read-visible and by-id-writable agree on the same fixture', async () => {
305+
const h = await boot({ shareLevel: 'edit' });
306+
const readable = await h.visibleContacts();
307+
const writable = await h.writableContacts();
308+
expect(readable.sort()).toEqual(writable.sort());
309+
// …and the agreement is not the empty agreement: exactly one row is
310+
// excluded, so "consistent" carries information.
311+
expect(readable).not.toContain('ct_eu');
312+
expect(readable).toHaveLength(2);
313+
});
314+
315+
it('a READ-level grant opens the child for reading but NOT for writing — the master\'s own verb boundary', async () => {
316+
const h = await boot({ shareLevel: 'read' });
317+
// Same asymmetry a DIRECT access of the master has: a `read` share widens
318+
// rows for reads only; `canEdit` refuses it. The derived faces must track
319+
// that per-verb answer rather than collapsing to one of them.
320+
expect(await h.visibleContacts()).toEqual(['ct_us', 'ct_own']);
321+
expect(await h.writableContacts()).toEqual(['ct_own']);
322+
await expect(h.updateContact('ct_us')).rejects.toThrow(/record sharing/);
323+
});
324+
325+
// ── boundary conditions ──────────────────────────────────────────────────
326+
it('the fold reads the grant table, resolves the master ONCE, and never re-enters the detail', async () => {
327+
const h = await boot({ shareLevel: 'edit' });
328+
h.store.find.mockClear();
329+
await h.visibleContacts();
330+
const reads = h.store.find.mock.calls.map((c: any[]) => String(c[0]));
331+
// The grant table was consulted — direct evidence the sharing half ran and
332+
// not merely that some filter came back.
333+
expect(reads).toContain('sys_record_share');
334+
// The master id set is resolved by ONE system-context read. More than one
335+
// would mean the derivation re-entered the middleware (v1 is single-level,
336+
// ADR-0055 — the master's own controlled_by_parent is not walked).
337+
expect(reads.filter((o) => o === 'crm_account')).toHaveLength(1);
338+
expect(reads).not.toContain('crm_contact');
339+
});
340+
341+
it('a deployment WITHOUT plugin-sharing is unchanged — RLS-only derivation, nothing to fold', async () => {
342+
// No sharing service means no owner scope and no grants anywhere in the
343+
// deployment, so a direct find of the master returns every row too: the
344+
// derived set stays point-for-point equal to it.
345+
const h = await boot({ shareLevel: 'edit', sharing: 'none' });
346+
expect(await h.visibleContacts()).toEqual(['ct_us', 'ct_eu', 'ct_own']);
347+
await expect(h.updateContact('ct_eu')).resolves.toBeUndefined();
348+
});
349+
350+
it('fail-closed: a sharing service that throws denies BOTH faces', async () => {
351+
const h = await boot({ shareLevel: 'edit', sharing: 'throws' });
352+
expect(await h.visibleContacts()).toEqual([]);
353+
await expect(h.updateContact('ct_us')).rejects.toThrow(/record sharing/);
354+
});
355+
});

0 commit comments

Comments
 (0)