Skip to content

Commit a5a1e41

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0090 P4 — explain engine (D6), access-matrix snapshot gate, recalibrated benchmark (#2716)
* feat(security): ADR-0090 P4 — explain engine (D6), access-matrix snapshot gate, recalibrated benchmark Explain contract (spec): ExplainRequest/ExplainDecision/ExplainLayer — nine pipeline layers reported in order with per-layer contributor attribution and the composed read filter as the machine artifact; carries D10 dual attribution (principalKind / onBehalfOf). Explain engine (plugin-security): explainAccess walks the SAME resolution/ evaluator/FLS/RLS code the enforcement middleware uses (injected from SecurityPlugin) — explained by construction. Exposed on the 'security' kernel service as explain(); explaining another user requires manage_users (target context reconstructed via buildContextForUser with everyone-anchor semantics). Access-matrix snapshot gate (lint + cli): buildAccessMatrix derives the (permission set × object) capability matrix purely from metadata; diffAccessMatrix renders semantic review lines; os compile fails on drift against a committed access-matrix.json until re-snapshotted with --update-access-matrix. Seeded for examples/app-crm. Benchmark (Addendum): scripts/bench/permission-bench.mts — single-org 10k users × 1M rows; asserts per-request cost independent of population. Passing at ~0.1µs/eval, 59ms per 1M-row IN-set scan. NOTE: code + unit suites verified (plugin-security 235, lint 161, spec-security 111, cli green); full dogfood + example builds pending — run before opening the PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix(cli): drop exists-then-read/write TOCTOU in access-matrix gate (CodeQL) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e097576 commit a5a1e41

15 files changed

Lines changed: 986 additions & 6 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/cli": minor
5+
"@objectstack/plugin-security": minor
6+
---
7+
8+
ADR-0090 P4 — explain engine (D6), access-matrix snapshot gate, recalibrated benchmark.
9+
10+
**Explain contract (@objectstack/spec).** `ExplainRequestSchema` / `ExplainDecisionSchema` / `ExplainLayerSchema`: `explain(principal, object, operation)` reports the verdict of every evaluation-pipeline layer in order (principal → required_permissions → object_crud → fls → owd_baseline → depth → sharing → vama_bypass → rls), with per-layer contributor attribution (which permission set, reached via which position/baseline) and — for reads — the composed row filter as the machine artifact. Carries the D10 dual attribution (`principalKind`, `onBehalfOf`).
11+
12+
**Explain engine (@objectstack/plugin-security).** `explainAccess` is "explained by construction": it calls the SAME permission-set resolution, evaluator, FLS mask, and RLS composition the enforcement middleware calls (injected from `SecurityPlugin`), so the report cannot drift from enforcement. Exposed on the `security` kernel service as `explain(request, callerContext)`; explaining another user requires `manage_users` (the target's context is reconstructed from `sys_user_position` / `sys_user_permission_set` with everyone-anchor semantics via `buildContextForUser`).
13+
14+
**Access-matrix snapshot gate (@objectstack/lint + os compile).** `buildAccessMatrix(stack)` derives the (permission set × object) capability matrix purely from metadata; `diffAccessMatrix` renders semantic review lines ("'crm_admin' gains delete on 'crm_lead'", depth changes, OWD swings, entry add/remove). `os compile` gains an opt-in gate: with `access-matrix.json` committed next to the config, any drift fails the build with those lines until re-snapshotted via `--update-access-matrix` — every capability change becomes a reviewable diff. Seeded for `examples/app-crm`.
15+
16+
**Benchmark (ADR-0090 Addendum).** `scripts/bench/permission-bench.mts` — single-org 10k users × 1M rows per the recalibrated topology; asserts the O()-shape property (per-request cost independent of user population; unit-depth IN-set cost tracks unit size). Passing at 0.1µs/eval and 59ms/1M-row IN-set scan.

docs/design/permission-model.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,15 @@ being **structured data**:
212212
`@objectstack/lint`, gating `os compile`): unset OWD, retired OWD aliases, external dial wider
213213
than internal, non-admin superuser wildcards, high-privilege everyone-suggested sets, forbidden
214214
vocabulary — each rule traceable to an observed failure class and mirrored by a runtime gate.
215-
3. **Access-matrix snapshot**: publishes evaluate representative positions × objects and diff
216-
against the committed matrix; an unchanged matrix auto-passes, a changed one raises a human gate
217-
showing the *semantic* impact ("grants `sales_rep` (~1,200 users) org-wide read on
218-
`crm_opportunity`").
215+
3. **Access-matrix snapshot** (landed in P4 as `buildAccessMatrix`/`diffAccessMatrix` in
216+
`@objectstack/lint`, gating `os compile` when `access-matrix.json` is committed): the
217+
(permission set × object) matrix is derived purely from metadata and diffed on every build; an
218+
unchanged matrix auto-passes, a changed one FAILS the build with the *semantic* impact
219+
("`crm_admin` gains delete on `crm_lead`", depth changes, OWD swings) until the snapshot is
220+
re-generated with `--update-access-matrix` — the snapshot's git diff is the review artifact.
221+
The runtime side is the **explain engine** (P4, `security` service `explain(request)`): the
222+
nine-layer pipeline reported per-layer with contributor attribution, walking the same code the
223+
middleware enforces with — explained by construction.
219224
4. **Tiered human gates**: AI drafts anything; publishing security-domain metadata requires human
220225
approval of that semantic diff. Non-security metadata auto-publishes.
221226
5. **Fail-closed runtime** (ADR-0049/#2565 posture) + post-publish telemetry as the last parachute.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"version": 1,
3+
"entries": [
4+
{
5+
"permissionSet": "crm_sales_user",
6+
"object": "crm_account",
7+
"create": true,
8+
"read": true,
9+
"edit": true,
10+
"delete": false,
11+
"viewAllRecords": false,
12+
"modifyAllRecords": false,
13+
"sharingModel": "public_read_write"
14+
},
15+
{
16+
"permissionSet": "crm_sales_user",
17+
"object": "crm_activity",
18+
"create": true,
19+
"read": true,
20+
"edit": true,
21+
"delete": false,
22+
"viewAllRecords": false,
23+
"modifyAllRecords": false,
24+
"sharingModel": "public_read_write"
25+
},
26+
{
27+
"permissionSet": "crm_sales_user",
28+
"object": "crm_contact",
29+
"create": true,
30+
"read": true,
31+
"edit": true,
32+
"delete": false,
33+
"viewAllRecords": false,
34+
"modifyAllRecords": false,
35+
"sharingModel": "public_read_write"
36+
},
37+
{
38+
"permissionSet": "crm_sales_user",
39+
"object": "crm_lead",
40+
"create": true,
41+
"read": true,
42+
"edit": true,
43+
"delete": false,
44+
"viewAllRecords": false,
45+
"modifyAllRecords": false,
46+
"sharingModel": "public_read_write"
47+
},
48+
{
49+
"permissionSet": "crm_sales_user",
50+
"object": "crm_opportunity",
51+
"create": true,
52+
"read": true,
53+
"edit": true,
54+
"delete": false,
55+
"viewAllRecords": false,
56+
"modifyAllRecords": false,
57+
"sharingModel": "public_read_write"
58+
},
59+
{
60+
"permissionSet": "guest_portal",
61+
"object": "crm_lead",
62+
"create": true,
63+
"read": false,
64+
"edit": false,
65+
"delete": false,
66+
"viewAllRecords": false,
67+
"modifyAllRecords": false,
68+
"sharingModel": "public_read_write"
69+
}
70+
]
71+
}

packages/cli/src/commands/compile.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateWidgetBindings } from '@objectstack/lint';
1313
import { validateResponsiveStyles } from '@objectstack/lint';
14-
import { validateSecurityPosture } from '@objectstack/lint';
14+
import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
1515
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
1616
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
1717
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
@@ -58,6 +58,10 @@ export default class Compile extends Command {
5858
default: false,
5959
hidden: true,
6060
}),
61+
'update-access-matrix': Flags.boolean({
62+
description: '[ADR-0090 D6] Rewrite access-matrix.json from the current stack instead of failing on drift. Review the resulting diff — it IS the capability change.',
63+
default: false,
64+
}),
6165
};
6266

6367
async run(): Promise<void> {
@@ -357,6 +361,46 @@ export default class Compile extends Command {
357361
}
358362
}
359363

364+
// 3f. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when
365+
// `access-matrix.json` sits next to the config, the (permission set
366+
// × object) capability matrix derived from THIS build must match it
367+
// — a drift fails the build with a SEMANTIC diff ("'crm_admin'
368+
// gains delete on 'crm_lead'") until the snapshot is updated via
369+
// --update-access-matrix. An unchanged matrix auto-passes, so the
370+
// gate costs nothing until someone changes who-can-do-what.
371+
{
372+
const matrixPath = path.join(path.dirname(absolutePath), 'access-matrix.json');
373+
const currentMatrix = buildAccessMatrix(result.data as Record<string, unknown>);
374+
if (flags['update-access-matrix']) {
375+
// Unconditional write — creates or refreshes the snapshot.
376+
fs.writeFileSync(matrixPath, JSON.stringify(currentMatrix, null, 2) + '\n');
377+
if (!flags.json) printStep(`Access matrix snapshot written to ${path.relative(process.cwd(), matrixPath)} (ADR-0090 D6) — review the diff.`);
378+
} else {
379+
// Single read attempt (no exists-then-read TOCTOU): a missing file
380+
// means the app has not opted into the gate; an unreadable/corrupt
381+
// one is treated as empty so the drift report shows every entry.
382+
let committedRaw: string | null = null;
383+
try { committedRaw = fs.readFileSync(matrixPath, 'utf8'); } catch { committedRaw = null; }
384+
if (committedRaw !== null) {
385+
if (!flags.json) printStep('Checking access-matrix snapshot (ADR-0090 D6)...');
386+
let committed: any = { version: 1, entries: [] };
387+
try { committed = JSON.parse(committedRaw); } catch { /* corrupt = empty */ }
388+
const drift = diffAccessMatrix(committed, currentMatrix);
389+
if (drift.length > 0) {
390+
if (flags.json) {
391+
console.log(JSON.stringify({ success: false, error: 'access matrix drift', changes: drift }));
392+
this.exit(1);
393+
}
394+
console.log('');
395+
printError(`Access matrix drift (${drift.length} change${drift.length > 1 ? 's' : ''}) — capability changes must be reviewed`);
396+
for (const line of drift.slice(0, 50)) console.log(` • ${line}`);
397+
console.log(chalk.dim(' If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.'));
398+
this.exit(1);
399+
}
400+
}
401+
}
402+
}
403+
360404
// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
361405
// `docs: DocSchema[]` and lint the combined set (flatness,
362406
// namespace-prefixed names, MDX/image ban, same-package link
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// ADR-0090 D6 — access-matrix snapshot: pure build + semantic diff.
3+
4+
import { describe, it, expect } from 'vitest';
5+
import { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
6+
7+
const STACK = {
8+
objects: [
9+
{ name: 'crm_lead', sharingModel: 'private' },
10+
{ name: 'crm_account', sharingModel: 'public_read' },
11+
],
12+
permissions: [
13+
{
14+
name: 'sales_user',
15+
objects: {
16+
crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, readScope: 'unit' },
17+
crm_account: { allowRead: true },
18+
},
19+
},
20+
{
21+
name: 'crm_admin',
22+
objects: {
23+
crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true },
24+
},
25+
},
26+
],
27+
};
28+
29+
describe('buildAccessMatrix (ADR-0090 D6)', () => {
30+
it('derives one sorted row per (set × object) with OWD context', () => {
31+
const m = buildAccessMatrix(STACK);
32+
expect(m.version).toBe(1);
33+
expect(m.entries.map((e) => `${e.permissionSet}/${e.object}`)).toEqual([
34+
'crm_admin/crm_lead',
35+
'sales_user/crm_account',
36+
'sales_user/crm_lead',
37+
]);
38+
const lead = m.entries.find((e) => e.permissionSet === 'sales_user' && e.object === 'crm_lead')!;
39+
expect(lead).toMatchObject({ create: true, read: true, edit: true, delete: false, readScope: 'unit', sharingModel: 'private' });
40+
// VAMA implies the read bit even without allowRead.
41+
const admin = m.entries.find((e) => e.permissionSet === 'crm_admin')!;
42+
expect(admin.viewAllRecords).toBe(true);
43+
expect(admin.read).toBe(true);
44+
});
45+
46+
it('is deterministic (same input → identical JSON)', () => {
47+
expect(JSON.stringify(buildAccessMatrix(STACK))).toBe(JSON.stringify(buildAccessMatrix(STACK)));
48+
});
49+
});
50+
51+
describe('diffAccessMatrix (semantic review lines)', () => {
52+
it('identical matrices produce no lines', () => {
53+
expect(diffAccessMatrix(buildAccessMatrix(STACK), buildAccessMatrix(STACK))).toEqual([]);
54+
});
55+
56+
it('reports gained bits by name — the crm_admin-gains-delete shape', () => {
57+
const before = buildAccessMatrix(STACK);
58+
const after = buildAccessMatrix(JSON.parse(JSON.stringify(STACK)));
59+
const sales = (after.entries as any[]).find((e) => e.permissionSet === 'sales_user' && e.object === 'crm_lead');
60+
sales.delete = true;
61+
const lines = diffAccessMatrix(before, after);
62+
expect(lines).toEqual(["'sales_user' gains delete on 'crm_lead'"]);
63+
});
64+
65+
it('reports depth changes, entry additions/removals, and OWD swings', () => {
66+
const before = buildAccessMatrix(STACK);
67+
const mutated = JSON.parse(JSON.stringify(STACK));
68+
mutated.permissions[0].objects.crm_lead.readScope = 'org'; // depth widened
69+
delete mutated.permissions[0].objects.crm_account; // entry removed
70+
mutated.permissions[1].objects.crm_account = { allowRead: true }; // entry added
71+
mutated.objects[0].sharingModel = 'public_read_write'; // OWD swing
72+
const lines = diffAccessMatrix(before, buildAccessMatrix(mutated));
73+
expect(lines.some((l) => l.includes("read depth on 'crm_lead': unit → org"))).toBe(true);
74+
expect(lines.some((l) => l.includes("'sales_user' loses ALL access to 'crm_account'"))).toBe(true);
75+
expect(lines.some((l) => l.includes("'crm_admin' gains access to 'crm_account'"))).toBe(true);
76+
expect(lines.some((l) => l.includes('record baseline (OWD): private → public_read_write'))).toBe(true);
77+
});
78+
});
5.04 KB
Binary file not shown.

packages/lint/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,5 @@ export {
8686
SECURITY_PRIVATE_NO_READSCOPE,
8787
} from './validate-security-posture.js';
8888
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';
89+
90+
export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';

0 commit comments

Comments
 (0)