Skip to content

Commit ed61c9b

Browse files
os-zhuangclaude
andauthored
feat(spec): C2-α — explain 契约扩展到 record 粒度 (#2920) (#2928)
Extend the ADR-0090 D6 access-explanation contract to record granularity (schema only; no engine or UI changes). Adds optional recordId on the request; per-record attribution (ExplainMatchedRule, ExplainRecordAttribution, the optional ExplainLayer.record, and top-level ExplainDecision.record) on the response; and reserves the ADR-0095 kernel-chain vocabulary for the β engine: the tenant_isolation Layer 0 id, ExplainLayer.kernelTier, and the AuthzPosture ladder on principal. Every new field is optional/additive — object-level requests parse unchanged. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f3b8fa commit ed61c9b

4 files changed

Lines changed: 368 additions & 4 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): C2-α — extend the `explain` contract to record granularity (#2920)
6+
7+
The access-explanation contract (ADR-0090 D6) now carries the schema for
8+
record-level authorization explanations, so the β-phase engine
9+
(`plugin-security` + `plugin-sharing`) and the Studio/Setup "view as" UI can be
10+
built against a stable wire shape. Contract-only: no engine or UI changes ship
11+
here.
12+
13+
Request side:
14+
- `ExplainRequest.recordId` (optional) — explain one concrete record at row
15+
granularity. Omitted = the pre-C2 object-level question, answered identically
16+
(backward compatible).
17+
18+
Response side (row-level attribution, present only for record-grained requests):
19+
- New `ExplainMatchedRule` — a concrete share / sharing rule / ownership fact /
20+
team / territory / RLS policy / Layer 0 tenant filter that admitted or
21+
excluded the record at a layer, with its access level (`grants`), how it
22+
reached the principal (`via`), the row predicate (`predicate`), and its
23+
`effect` on the record.
24+
- New `ExplainRecordAttribution` — a layer's per-record determination
25+
(`outcome`, effective `rowFilter`, `matchesRecord`, matched `rules`), attached
26+
as the optional `ExplainLayer.record`.
27+
- New top-level `ExplainDecision.record` — the row-level bottom line
28+
(`recordId`, `visible`, `decidedBy`).
29+
30+
Reserved for the ADR-0095 kernel chain (β fills these; optional, backward
31+
compatible):
32+
- New `tenant_isolation` layer id (Layer 0, the always-first tenant wall).
33+
- New `ExplainLayer.kernelTier` (`layer_0_tenant` | `layer_1_business`) so a
34+
consumer can tell the tenant wall from business RLS without hard-coding ids.
35+
- New `AuthzPosture` enum (`PLATFORM_ADMIN` > `TENANT_ADMIN` > `MEMBER` >
36+
`EXTERNAL`) exposed as the optional `ExplainDecision.principal.posture`.
37+
38+
Backward compatibility: every new field is optional or additive; existing
39+
object-level requests and reports parse unchanged. The contract test locks the
40+
new field shapes alongside the existing ones.

packages/spec/api-surface.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3913,14 +3913,20 @@
39133913
"AdminScope (type)",
39143914
"AdminScopeInput (type)",
39153915
"AdminScopeSchema (const)",
3916+
"AuthzPosture (type)",
3917+
"AuthzPostureSchema (const)",
39163918
"CriteriaSharingRule (type)",
39173919
"CriteriaSharingRuleSchema (const)",
39183920
"ExplainDecision (type)",
39193921
"ExplainDecisionSchema (const)",
39203922
"ExplainLayer (type)",
39213923
"ExplainLayerSchema (const)",
3924+
"ExplainMatchedRule (type)",
3925+
"ExplainMatchedRuleSchema (const)",
39223926
"ExplainOperation (type)",
39233927
"ExplainOperationSchema (const)",
3928+
"ExplainRecordAttribution (type)",
3929+
"ExplainRecordAttributionSchema (const)",
39243930
"ExplainRequest (type)",
39253931
"ExplainRequestInput (type)",
39263932
"ExplainRequestSchema (const)",

packages/spec/src/security/explain.test.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {
2323
ExplainDecisionSchema,
2424
AccessMatrixEntrySchema,
2525
AccessMatrixSchema,
26+
AuthzPostureSchema,
27+
ExplainMatchedRuleSchema,
28+
ExplainRecordAttributionSchema,
2629
} from './explain.zod';
2730

2831
describe('ExplainOperationSchema — the operation vocabulary is fixed', () => {
@@ -36,15 +39,18 @@ describe('ExplainOperationSchema — the operation vocabulary is fixed', () => {
3639
});
3740
});
3841

39-
describe('ExplainLayerSchema — the nine-layer pipeline + contributor shape', () => {
42+
describe('ExplainLayerSchema — the ten-layer pipeline + contributor shape', () => {
4043
const LAYERS = [
44+
// [ADR-0095 D1] Layer 0 — the always-first tenant wall, ahead of principal.
45+
'tenant_isolation',
4146
'principal', 'required_permissions', 'object_crud', 'fls',
4247
'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls',
4348
];
44-
it('locks the nine layer ids, in order', () => {
49+
it('locks the ten layer ids, including the ADR-0095 tenant_isolation Layer 0', () => {
4550
for (const layer of LAYERS) {
4651
expect(ExplainLayerSchema.parse({ layer, verdict: 'neutral', detail: 'x' }).layer).toBe(layer);
4752
}
53+
// A near-miss ('tenant') is still rejected — only the full id is a member.
4854
expect(() => ExplainLayerSchema.parse({ layer: 'tenant', verdict: 'neutral', detail: 'x' })).toThrow();
4955
});
5056

@@ -86,6 +92,76 @@ describe('ExplainLayerSchema — the nine-layer pipeline + contributor shape', (
8692
contributors: [{ kind: 'position', name: 'x', state: 'suspended' }],
8793
})).toThrow();
8894
});
95+
96+
it('[C2] kernelTier + record are optional — object-level layers omit them', () => {
97+
const bare = ExplainLayerSchema.parse({ layer: 'sharing', verdict: 'not_applicable', detail: 'x' });
98+
expect(bare.kernelTier).toBeUndefined();
99+
expect(bare.record).toBeUndefined();
100+
});
101+
102+
it('[ADR-0095 D1] kernelTier tags Layer 0 vs Layer 1; rejects other values', () => {
103+
expect(ExplainLayerSchema.parse({
104+
layer: 'tenant_isolation', verdict: 'narrows', detail: 'x', kernelTier: 'layer_0_tenant',
105+
}).kernelTier).toBe('layer_0_tenant');
106+
expect(ExplainLayerSchema.parse({
107+
layer: 'rls', verdict: 'narrows', detail: 'x', kernelTier: 'layer_1_business',
108+
}).kernelTier).toBe('layer_1_business');
109+
expect(() => ExplainLayerSchema.parse({
110+
layer: 'rls', verdict: 'narrows', detail: 'x', kernelTier: 'layer_2',
111+
})).toThrow();
112+
});
113+
114+
it('[C2] a record-grained layer carries per-record attribution', () => {
115+
const layer = ExplainLayerSchema.parse({
116+
layer: 'sharing', verdict: 'widens', detail: 'x', kernelTier: 'layer_1_business',
117+
record: {
118+
outcome: 'admitted',
119+
rowFilter: { owner: 'u2' },
120+
matchesRecord: true,
121+
rules: [
122+
{ kind: 'record_share', name: 'share_row_42', grants: 'read', via: 'group:sales_team', effect: 'admits' },
123+
{ kind: 'ownership', name: 'owner-check', via: 'owner', effect: 'neutral' },
124+
],
125+
detail: 'admitted: an explicit share targets this row',
126+
},
127+
});
128+
expect(layer.record?.outcome).toBe('admitted');
129+
expect(layer.record?.matchesRecord).toBe(true);
130+
expect(layer.record?.rules.map((r) => r.kind)).toEqual(['record_share', 'ownership']);
131+
expect(layer.record?.rules[0].grants).toBe('read');
132+
});
133+
});
134+
135+
describe('[ADR-0095 D2] AuthzPostureSchema — the monotonic posture ladder', () => {
136+
it('locks the four rungs', () => {
137+
for (const rung of ['PLATFORM_ADMIN', 'TENANT_ADMIN', 'MEMBER', 'EXTERNAL']) {
138+
expect(AuthzPostureSchema.parse(rung)).toBe(rung);
139+
}
140+
expect(() => AuthzPostureSchema.parse('SUPERUSER')).toThrow();
141+
});
142+
});
143+
144+
describe('[C2] ExplainMatchedRule / ExplainRecordAttribution — row-level attribution', () => {
145+
it('matched rule locks its kinds, effect, and optional grants/via/predicate', () => {
146+
for (const kind of ['tenant_filter', 'owd_baseline', 'ownership', 'record_share', 'sharing_rule', 'team', 'territory', 'rls_policy']) {
147+
expect(ExplainMatchedRuleSchema.parse({ kind, name: 'r', effect: 'admits' }).kind).toBe(kind);
148+
}
149+
expect(() => ExplainMatchedRuleSchema.parse({ kind: 'guess', name: 'r', effect: 'admits' })).toThrow();
150+
expect(() => ExplainMatchedRuleSchema.parse({ kind: 'ownership', name: 'r', effect: 'maybe' })).toThrow();
151+
const full = ExplainMatchedRuleSchema.parse({
152+
kind: 'sharing_rule', name: 'open_leads_to_sales', grants: 'edit',
153+
via: 'criteria: status == open', predicate: { status: 'open' }, effect: 'admits',
154+
});
155+
expect(full.grants).toBe('edit');
156+
expect(full.predicate).toEqual({ status: 'open' });
157+
});
158+
159+
it('record attribution defaults rules to [] and locks outcome', () => {
160+
const bare = ExplainRecordAttributionSchema.parse({ outcome: 'excluded' });
161+
expect(bare.rules).toEqual([]);
162+
expect(bare.matchesRecord).toBeUndefined();
163+
expect(() => ExplainRecordAttributionSchema.parse({ outcome: 'hidden' })).toThrow();
164+
});
89165
});
90166

91167
describe('ExplainRequestSchema — the request contract', () => {
@@ -96,6 +172,15 @@ describe('ExplainRequestSchema — the request contract', () => {
96172
expect(ExplainRequestSchema.parse({ object: 'x', operation: 'update', userId: 'u2' }).userId).toBe('u2');
97173
expect(() => ExplainRequestSchema.parse({ operation: 'read' })).toThrow();
98174
});
175+
176+
it('[C2] recordId is optional and round-trips; object-level requests stay backward-compatible', () => {
177+
// Backward compat: the pre-C2 object-level request still parses, recordId absent.
178+
const objectLevel = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'read' });
179+
expect(objectLevel.recordId).toBeUndefined();
180+
// Record-grained request round-trips the recordId.
181+
const recordLevel = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'update', recordId: 'lr_42' });
182+
expect(recordLevel.recordId).toBe('lr_42');
183+
});
99184
});
100185

101186
describe('ExplainDecisionSchema — the full decision report L3 consumes', () => {
@@ -131,6 +216,68 @@ describe('ExplainDecisionSchema — the full decision report L3 consumes', () =>
131216
expect(parsed.readFilter).toEqual({ owner: 'u2' });
132217
});
133218

219+
it('[C2] round-trips a record-grained decision with posture + per-record trace', () => {
220+
const decision = {
221+
allowed: false,
222+
object: 'leave_request',
223+
operation: 'update',
224+
principal: {
225+
userId: 'u2',
226+
positions: ['approver', 'everyone'],
227+
permissionSets: ['approve_set', 'member_default'],
228+
principalKind: 'human',
229+
posture: 'MEMBER',
230+
},
231+
layers: [
232+
{
233+
layer: 'tenant_isolation', verdict: 'narrows', detail: '…', kernelTier: 'layer_0_tenant',
234+
record: { outcome: 'admitted', matchesRecord: true, rules: [{ kind: 'tenant_filter', name: 'org_wall', effect: 'admits' }] },
235+
},
236+
{
237+
layer: 'sharing', verdict: 'not_applicable', detail: '…', kernelTier: 'layer_1_business',
238+
record: {
239+
outcome: 'excluded',
240+
rowFilter: { owner: 'u2' },
241+
matchesRecord: false,
242+
rules: [{ kind: 'ownership', name: 'owner-check', via: 'owner', effect: 'excludes' }],
243+
detail: 'excluded: you are not the owner and no share targets this row',
244+
},
245+
},
246+
],
247+
record: { recordId: 'lr_42', visible: false, decidedBy: 'sharing' },
248+
};
249+
const parsed = ExplainDecisionSchema.parse(decision);
250+
expect(parsed.principal.posture).toBe('MEMBER');
251+
expect(parsed.record?.recordId).toBe('lr_42');
252+
expect(parsed.record?.visible).toBe(false);
253+
expect(parsed.record?.decidedBy).toBe('sharing');
254+
expect(parsed.layers[0].kernelTier).toBe('layer_0_tenant');
255+
expect(parsed.layers[1].record?.outcome).toBe('excluded');
256+
});
257+
258+
it('[C2] object-level decisions omit record + posture (backward-compatible)', () => {
259+
const parsed = ExplainDecisionSchema.parse({
260+
allowed: true, object: 'x', operation: 'read',
261+
principal: { userId: 'u2' },
262+
layers: [{ layer: 'rls', verdict: 'narrows', detail: 'x', contributors: [] }],
263+
readFilter: { owner: 'u2' },
264+
});
265+
expect(parsed.record).toBeUndefined();
266+
expect(parsed.principal.posture).toBeUndefined();
267+
});
268+
269+
it('[ADR-0095] rejects an unknown posture or decidedBy layer', () => {
270+
expect(() => ExplainDecisionSchema.parse({
271+
allowed: true, object: 'x', operation: 'read',
272+
principal: { userId: 'u', posture: 'GOD_MODE' }, layers: [],
273+
})).toThrow();
274+
expect(() => ExplainDecisionSchema.parse({
275+
allowed: true, object: 'x', operation: 'read',
276+
principal: { userId: 'u' }, layers: [],
277+
record: { recordId: 'r1', visible: true, decidedBy: 'quantum' },
278+
})).toThrow();
279+
});
280+
134281
it('principal.userId is nullable (anonymous), positions/permissionSets default to []', () => {
135282
const parsed = ExplainDecisionSchema.parse({
136283
allowed: false, object: 'x', operation: 'read',

0 commit comments

Comments
 (0)