Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/c2-beta-explain-record-grained.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-security': minor
'@objectstack/rest': patch
---

feat(plugin-security): C2-β — explain 引擎 record 粒度行级归因 (#2920)

`explain(principal, object, operation, recordId?)` 现支持记录级解释。透传 `recordId` 时,引擎在对象级流水线之上叠加**行级归因**,全部复用 enforcement 同一批函数(explained-by-construction):

- **`tenant_isolation` Layer 0**:作为永远最先的层被 prepend;每层打上 `kernelTier`(`layer_0_tenant` vs `layer_1_business`),可区分「租户墙挡的」还是「业务 RLS 挡的」。
- **每层 `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)。
- **顶层 `record` 判定**:`visible` + `decidedBy` 决定性层。读走复合行过滤匹配,写走 sharing service 的 `canEdit`(均为 enforcement 原语)。
- **`principal.posture`**:ADR-0095 D2 档位(PLATFORM_ADMIN/TENANT_ADMIN/MEMBER/EXTERNAL)的 B2 stand-in 派生(复用 `resolveAuthzContext` 已投影的 platform_admin / org 角色证据),待 B2 合并后替换。
- `computeRlsFilter` 重构为 `computeLayeredRlsFilter`(暴露 `{ layer0, layer1 }` 拆分)+ 薄 andCompose 包装,单一代码路径,行级归因不会与执行漂移。
- REST `security.explain`(GET/POST)接受可选 `recordId`。

**向后兼容**:无 `recordId` 的对象级请求输出 **byte-identical**——无 `tenant_isolation` 层、无 `kernelTier`、无 `posture`、无 `record`。
134 changes: 134 additions & 0 deletions packages/plugins/plugin-security/src/explain-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,140 @@ describe('explainAccess (ADR-0090 D6)', () => {
});
});

describe('explainAccess — record-grained (C2 / ADR-0095)', () => {
const REC_CTX = { userId: 'u1', tenantId: 'org1', positions: ['sales_rep', 'everyone'], permissions: [] };

function recDeps(opts: {
layered?: { layer0: any; layer1: any };
record?: Record<string, unknown> | null;
sharingFilter?: unknown;
shares?: any[];
canEdit?: boolean;
sets?: any[];
schema?: any;
} = {}): ExplainEngineDeps {
const base = makeDeps({ sets: opts.sets, schema: opts.schema });
return {
...base,
computeLayeredRlsFilter: async () => opts.layered ?? { layer0: null, layer1: null },
fetchRecord: async () => (opts.record !== undefined ? opts.record : { id: 'r1', organization_id: 'org1', owner_id: 'u1' }),
sharingReadFilter: async () => (opts.sharingFilter !== undefined ? opts.sharingFilter : null),
listRecordShares: async () => opts.shares ?? [],
canEditRecord: async () => opts.canEdit ?? false,
};
}

it('leaves the object-level report byte-identical when no recordId is supplied (backward compat)', async () => {
const d = await explainAccess(recDeps(), { object: 'leave_request', operation: 'read', context: REC_CTX });
expect(d.record).toBeUndefined();
expect(d.principal).not.toHaveProperty('posture');
expect(d.layers.map((l) => l.layer)).toEqual([
'principal', 'required_permissions', 'object_crud', 'fls',
'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls',
]);
expect(d.layers.every((l) => l.kernelTier === undefined)).toBe(true);
expect(d.layers.every((l) => l.record === undefined)).toBe(true);
});

it('prepends the tenant_isolation Layer 0, tags every layer with kernelTier, and resolves posture', async () => {
const d = await explainAccess(
recDeps({ layered: { layer0: { organization_id: 'org1' }, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u1' } }),
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
);
expect(d.layers[0].layer).toBe('tenant_isolation');
expect(d.layers[0].kernelTier).toBe('layer_0_tenant');
expect(d.layers.find((l) => l.layer === 'rls')!.kernelTier).toBe('layer_1_business');
expect(d.principal.posture).toBe('MEMBER');
expect(d.record).toMatchObject({ recordId: 'r1', visible: true });
});

it('derives PLATFORM_ADMIN posture from the platform_admin position', async () => {
const d = await explainAccess(
recDeps({ sets: [ADMIN], layered: { layer0: null, layer1: null } }),
{ object: 'leave_request', operation: 'read', context: { userId: 'a1', tenantId: 'org1', positions: ['platform_admin', 'everyone'], permissions: [] }, recordId: 'r1' },
);
expect(d.principal.posture).toBe('PLATFORM_ADMIN');
});

it('Layer 0 (the tenant wall) excludes a cross-org record — decidedBy tenant_isolation', async () => {
const d = await explainAccess(
recDeps({ layered: { layer0: { organization_id: 'org1' }, layer1: null }, record: { id: 'r1', organization_id: 'org2', owner_id: 'u1' } }),
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
);
const tenant = d.layers.find((l) => l.layer === 'tenant_isolation')!;
expect(tenant.record!.outcome).toBe('excluded');
expect(tenant.record!.rules[0]).toMatchObject({ kind: 'tenant_filter', effect: 'excludes' });
expect(d.record).toMatchObject({ visible: false, decidedBy: 'tenant_isolation' });
});

it('Layer 1 (business RLS) excludes a non-matching record — decidedBy rls', async () => {
const d = await explainAccess(
recDeps({ layered: { layer0: null, layer1: { status: 'open' } }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u1', status: 'closed' } }),
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
);
const rls = d.layers.find((l) => l.layer === 'rls')!;
expect(rls.record!.outcome).toBe('excluded');
expect(rls.record!.matchesRecord).toBe(false);
expect(d.record).toMatchObject({ visible: false, decidedBy: 'rls' });
});

it('a record_share admits a non-owner on a private object — sharing admits, decidedBy sharing', async () => {
const d = await explainAccess(
recDeps({
layered: { layer0: null, layer1: null },
record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' },
shares: [{ id: 'shr_1', recipient_type: 'user', recipient_id: 'u1', access_level: 'read', source: 'manual' }],
sharingFilter: { $or: [{ owner_id: 'u1' }, { id: { $in: ['r1'] } }] },
}),
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
);
const sharing = d.layers.find((l) => l.layer === 'sharing')!;
expect(sharing.record!.outcome).toBe('admitted');
expect(sharing.record!.rules[0]).toMatchObject({ kind: 'record_share', effect: 'admits', grants: 'read' });
expect(d.record).toMatchObject({ visible: true, decidedBy: 'sharing' });
});

it('private object, non-owner, no admitting share — not visible, decidedBy sharing', async () => {
const d = await explainAccess(
recDeps({ layered: { layer0: null, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, shares: [], sharingFilter: { owner_id: 'u1' } }),
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' },
);
expect(d.layers.find((l) => l.layer === 'sharing')!.record!.outcome).toBe('excluded');
expect(d.record).toMatchObject({ visible: false, decidedBy: 'sharing' });
});

it('a missing record yields not_evaluated row layers and an invisible verdict', async () => {
const d = await explainAccess(
recDeps({ record: null }),
{ object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'missing' },
);
expect(d.record).toMatchObject({ visible: false });
expect(d.record!.decidedBy).toBeUndefined();
expect(d.layers.find((l) => l.layer === 'rls')!.record!.outcome).toBe('not_evaluated');
expect(d.layers.find((l) => l.layer === 'tenant_isolation')!.record!.outcome).toBe('not_evaluated');
});

it('write ops use the sharing service canEdit as the by-construction verdict', async () => {
const editor = PermissionSetSchema.parse({ name: 'editor', objects: { leave_request: { allowRead: true, allowEdit: true } } });
const d = await explainAccess(
recDeps({ sets: [editor], layered: { layer0: null, layer1: null }, record: { id: 'r1', organization_id: 'org1', owner_id: 'u_other' }, canEdit: true, shares: [] }),
{ object: 'leave_request', operation: 'update', context: REC_CTX, recordId: 'r1' },
);
expect(d.layers.find((l) => l.layer === 'sharing')!.record!.outcome).toBe('admitted');
expect(d.record).toMatchObject({ recordId: 'r1', visible: true });
});

it('degrades gracefully with no record-grained deps — object-level layers plus a best-effort verdict', async () => {
// Only the base object-level deps: recordId is given but fetchRecord /
// computeLayeredRlsFilter etc. are absent (e.g. no plugin-sharing).
const d = await explainAccess(makeDeps(), { object: 'leave_request', operation: 'read', context: REC_CTX, recordId: 'r1' });
expect(d.layers[0].layer).toBe('tenant_isolation');
expect(d.record).toMatchObject({ recordId: 'r1' });
// record could not be fetched → not_evaluated tenant + invisible.
expect(d.layers.find((l) => l.layer === 'tenant_isolation')!.record!.outcome).toBe('not_evaluated');
});
});

describe('buildContextForUser', () => {
const ql = {
async find(object: string, opts: any) {
Expand Down
Loading