Skip to content

Commit a581a65

Browse files
os-zhuangclaude
andauthored
feat(plugin-security): C2-β — explain 引擎 record 粒度行级归因 (#2920) (#2939)
* feat(plugin-security): C2-β — explain 引擎 record 粒度行级归因 (#2920) 带 recordId 时,explain 在对象级流水线上叠加行级归因,全部复用 enforcement 同一批函数(explained-by-construction): - tenant_isolation Layer 0 prepend + 每层 kernelTier(layer_0_tenant vs layer_1_business) - 每层 record 归因(tenant/owd_baseline/sharing/rls):outcome、有效 rowFilter、 matchesRecord(用 @objectstack/formula 的 matchesFilterCondition 求值)、命中 rules[] - 顶层 record 判定(visible + decidedBy);读走复合行过滤匹配,写走 sharing service canEdit - principal.posture(ADR-0095 D2 B2 stand-in 派生,留 TODO) - computeRlsFilter 拆为 computeLayeredRlsFilter({layer0,layer1})单一路径,不与执行漂移 - REST security.explain 接受可选 recordId 无 recordId 的对象级请求输出 byte-identical(向后兼容)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs * refactor(plugin-security): delegate explain admin-tier posture to core derivePosture Merge B2/B4 into C2-β and replace the explain-engine posture stand-in's PLATFORM_ADMIN/TENANT_ADMIN/MEMBER derivation with the single core derivePosture (ADR-0095 D3 — capability-derived, closes the TODO(B2)). The guest→EXTERNAL mapping stays in the explanation surface (the enforcement resolver's floor is MEMBER). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13749ec commit a581a65

5 files changed

Lines changed: 581 additions & 5 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/rest': patch
4+
---
5+
6+
feat(plugin-security): C2-β — explain 引擎 record 粒度行级归因 (#2920)
7+
8+
`explain(principal, object, operation, recordId?)` 现支持记录级解释。透传 `recordId` 时,引擎在对象级流水线之上叠加**行级归因**,全部复用 enforcement 同一批函数(explained-by-construction):
9+
10+
- **`tenant_isolation` Layer 0**:作为永远最先的层被 prepend;每层打上 `kernelTier``layer_0_tenant` vs `layer_1_business`),可区分「租户墙挡的」还是「业务 RLS 挡的」。
11+
- **每层 `record` 归因**(tenant / owd_baseline / sharing / rls):`outcome`(admitted/excluded/not_evaluated)、有效 `rowFilter``matchesRecord`(用 `@objectstack/formula``matchesFilterCondition` 对同一条 FilterCondition 求值)、命中的 `rules[]`(tenant_filter/owd_baseline/ownership/record_share/sharing_rule/team/rls_policy,含 grants/via/effect)。
12+
- **顶层 `record` 判定**`visible` + `decidedBy` 决定性层。读走复合行过滤匹配,写走 sharing service 的 `canEdit`(均为 enforcement 原语)。
13+
- **`principal.posture`**:ADR-0095 D2 档位(PLATFORM_ADMIN/TENANT_ADMIN/MEMBER/EXTERNAL)的 B2 stand-in 派生(复用 `resolveAuthzContext` 已投影的 platform_admin / org 角色证据),待 B2 合并后替换。
14+
- `computeRlsFilter` 重构为 `computeLayeredRlsFilter`(暴露 `{ layer0, layer1 }` 拆分)+ 薄 andCompose 包装,单一代码路径,行级归因不会与执行漂移。
15+
- REST `security.explain`(GET/POST)接受可选 `recordId`
16+
17+
**向后兼容**:无 `recordId` 的对象级请求输出 **byte-identical**——无 `tenant_isolation` 层、无 `kernelTier`、无 `posture`、无 `record`

packages/plugins/plugin-security/src/explain-engine.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,140 @@ describe('explainAccess (ADR-0090 D6)', () => {
170170
});
171171
});
172172

173+
describe('explainAccess — record-grained (C2 / ADR-0095)', () => {
174+
const REC_CTX = { userId: 'u1', tenantId: 'org1', positions: ['sales_rep', 'everyone'], permissions: [] };
175+
176+
function recDeps(opts: {
177+
layered?: { layer0: any; layer1: any };
178+
record?: Record<string, unknown> | null;
179+
sharingFilter?: unknown;
180+
shares?: any[];
181+
canEdit?: boolean;
182+
sets?: any[];
183+
schema?: any;
184+
} = {}): ExplainEngineDeps {
185+
const base = makeDeps({ sets: opts.sets, schema: opts.schema });
186+
return {
187+
...base,
188+
computeLayeredRlsFilter: async () => opts.layered ?? { layer0: null, layer1: null },
189+
fetchRecord: async () => (opts.record !== undefined ? opts.record : { id: 'r1', organization_id: 'org1', owner_id: 'u1' }),
190+
sharingReadFilter: async () => (opts.sharingFilter !== undefined ? opts.sharingFilter : null),
191+
listRecordShares: async () => opts.shares ?? [],
192+
canEditRecord: async () => opts.canEdit ?? false,
193+
};
194+
}
195+
196+
it('leaves the object-level report byte-identical when no recordId is supplied (backward compat)', async () => {
197+
const d = await explainAccess(recDeps(), { object: 'leave_request', operation: 'read', context: REC_CTX });
198+
expect(d.record).toBeUndefined();
199+
expect(d.principal).not.toHaveProperty('posture');
200+
expect(d.layers.map((l) => l.layer)).toEqual([
201+
'principal', 'required_permissions', 'object_crud', 'fls',
202+
'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls',
203+
]);
204+
expect(d.layers.every((l) => l.kernelTier === undefined)).toBe(true);
205+
expect(d.layers.every((l) => l.record === undefined)).toBe(true);
206+
});
207+
208+
it('prepends the tenant_isolation Layer 0, tags every layer with kernelTier, and resolves posture', async () => {
209+
const d = await explainAccess(
210+
recDeps({ layered: { layer0: { organization_id: 'org1' }, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u1' } }),
211+
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
212+
);
213+
expect(d.layers[0].layer).toBe('tenant_isolation');
214+
expect(d.layers[0].kernelTier).toBe('layer_0_tenant');
215+
expect(d.layers.find((l) => l.layer === 'rls')!.kernelTier).toBe('layer_1_business');
216+
expect(d.principal.posture).toBe('MEMBER');
217+
expect(d.record).toMatchObject({ recordId: 'r1', visible: true });
218+
});
219+
220+
it('derives PLATFORM_ADMIN posture from the platform_admin position', async () => {
221+
const d = await explainAccess(
222+
recDeps({ sets: [ADMIN], layered: { layer0: null, layer1: null } }),
223+
{ object: 'leave_request', operation: 'read', context: { userId: 'a1', tenantId: 'org1', positions: ['platform_admin', 'everyone'], permissions: [] }, recordId: 'r1' },
224+
);
225+
expect(d.principal.posture).toBe('PLATFORM_ADMIN');
226+
});
227+
228+
it('Layer 0 (the tenant wall) excludes a cross-org record — decidedBy tenant_isolation', async () => {
229+
const d = await explainAccess(
230+
recDeps({ layered: { layer0: { organization_id: 'org1' }, layer1: null }, record: { id: 'r1', organization_id: 'org2', owner_id: 'u1' } }),
231+
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
232+
);
233+
const tenant = d.layers.find((l) => l.layer === 'tenant_isolation')!;
234+
expect(tenant.record!.outcome).toBe('excluded');
235+
expect(tenant.record!.rules[0]).toMatchObject({ kind: 'tenant_filter', effect: 'excludes' });
236+
expect(d.record).toMatchObject({ visible: false, decidedBy: 'tenant_isolation' });
237+
});
238+
239+
it('Layer 1 (business RLS) excludes a non-matching record — decidedBy rls', async () => {
240+
const d = await explainAccess(
241+
recDeps({ layered: { layer0: null, layer1: { status: 'open' } }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u1', status: 'closed' } }),
242+
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
243+
);
244+
const rls = d.layers.find((l) => l.layer === 'rls')!;
245+
expect(rls.record!.outcome).toBe('excluded');
246+
expect(rls.record!.matchesRecord).toBe(false);
247+
expect(d.record).toMatchObject({ visible: false, decidedBy: 'rls' });
248+
});
249+
250+
it('a record_share admits a non-owner on a private object — sharing admits, decidedBy sharing', async () => {
251+
const d = await explainAccess(
252+
recDeps({
253+
layered: { layer0: null, layer1: null },
254+
record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' },
255+
shares: [{ id: 'shr_1', recipient_type: 'user', recipient_id: 'u1', access_level: 'read', source: 'manual' }],
256+
sharingFilter: { $or: [{ owner_id: 'u1' }, { id: { $in: ['r1'] } }] },
257+
}),
258+
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
259+
);
260+
const sharing = d.layers.find((l) => l.layer === 'sharing')!;
261+
expect(sharing.record!.outcome).toBe('admitted');
262+
expect(sharing.record!.rules[0]).toMatchObject({ kind: 'record_share', effect: 'admits', grants: 'read' });
263+
expect(d.record).toMatchObject({ visible: true, decidedBy: 'sharing' });
264+
});
265+
266+
it('private object, non-owner, no admitting share — not visible, decidedBy sharing', async () => {
267+
const d = await explainAccess(
268+
recDeps({ layered: { layer0: null, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, shares: [], sharingFilter: { owner_id: 'u1' } }),
269+
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
270+
);
271+
expect(d.layers.find((l) => l.layer === 'sharing')!.record!.outcome).toBe('excluded');
272+
expect(d.record).toMatchObject({ visible: false, decidedBy: 'sharing' });
273+
});
274+
275+
it('a missing record yields not_evaluated row layers and an invisible verdict', async () => {
276+
const d = await explainAccess(
277+
recDeps({ record: null }),
278+
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'missing' },
279+
);
280+
expect(d.record).toMatchObject({ visible: false });
281+
expect(d.record!.decidedBy).toBeUndefined();
282+
expect(d.layers.find((l) => l.layer === 'rls')!.record!.outcome).toBe('not_evaluated');
283+
expect(d.layers.find((l) => l.layer === 'tenant_isolation')!.record!.outcome).toBe('not_evaluated');
284+
});
285+
286+
it('write ops use the sharing service canEdit as the by-construction verdict', async () => {
287+
const editor = PermissionSetSchema.parse({ name: 'editor', objects: { leave_request: { allowRead: true, allowEdit: true } } });
288+
const d = await explainAccess(
289+
recDeps({ sets: [editor], layered: { layer0: null, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, canEdit: true, shares: [] }),
290+
{ object: 'leave_request', operation: 'update', context: REC_CTX, recordId: 'r1' },
291+
);
292+
expect(d.layers.find((l) => l.layer === 'sharing')!.record!.outcome).toBe('admitted');
293+
expect(d.record).toMatchObject({ recordId: 'r1', visible: true });
294+
});
295+
296+
it('degrades gracefully with no record-grained deps — object-level layers plus a best-effort verdict', async () => {
297+
// Only the base object-level deps: recordId is given but fetchRecord /
298+
// computeLayeredRlsFilter etc. are absent (e.g. no plugin-sharing).
299+
const d = await explainAccess(makeDeps(), { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' });
300+
expect(d.layers[0].layer).toBe('tenant_isolation');
301+
expect(d.record).toMatchObject({ recordId: 'r1' });
302+
// record could not be fetched → not_evaluated tenant + invisible.
303+
expect(d.layers.find((l) => l.layer === 'tenant_isolation')!.record!.outcome).toBe('not_evaluated');
304+
});
305+
});
306+
173307
describe('buildContextForUser', () => {
174308
const ql = {
175309
async find(object: string, opts: any) {

0 commit comments

Comments
 (0)