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
16 changes: 16 additions & 0 deletions .changeset/adr-0090-p4-explain-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/cli": minor
"@objectstack/plugin-security": minor
---

ADR-0090 P4 — explain engine (D6), access-matrix snapshot gate, recalibrated benchmark.

**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`).

**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`).

**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`.

**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.
13 changes: 9 additions & 4 deletions docs/design/permission-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,15 @@ being **structured data**:
`@objectstack/lint`, gating `os compile`): unset OWD, retired OWD aliases, external dial wider
than internal, non-admin superuser wildcards, high-privilege everyone-suggested sets, forbidden
vocabulary — each rule traceable to an observed failure class and mirrored by a runtime gate.
3. **Access-matrix snapshot**: publishes evaluate representative positions × objects and diff
against the committed matrix; an unchanged matrix auto-passes, a changed one raises a human gate
showing the *semantic* impact ("grants `sales_rep` (~1,200 users) org-wide read on
`crm_opportunity`").
3. **Access-matrix snapshot** (landed in P4 as `buildAccessMatrix`/`diffAccessMatrix` in
`@objectstack/lint`, gating `os compile` when `access-matrix.json` is committed): the
(permission set × object) matrix is derived purely from metadata and diffed on every build; an
unchanged matrix auto-passes, a changed one FAILS the build with the *semantic* impact
("`crm_admin` gains delete on `crm_lead`", depth changes, OWD swings) until the snapshot is
re-generated with `--update-access-matrix` — the snapshot's git diff is the review artifact.
The runtime side is the **explain engine** (P4, `security` service `explain(request)`): the
nine-layer pipeline reported per-layer with contributor attribution, walking the same code the
middleware enforces with — explained by construction.
4. **Tiered human gates**: AI drafts anything; publishing security-domain metadata requires human
approval of that semantic diff. Non-security metadata auto-publishes.
5. **Fail-closed runtime** (ADR-0049/#2565 posture) + post-publish telemetry as the last parachute.
Expand Down
71 changes: 71 additions & 0 deletions examples/app-crm/access-matrix.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"version": 1,
"entries": [
{
"permissionSet": "crm_sales_user",
"object": "crm_account",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
{
"permissionSet": "crm_sales_user",
"object": "crm_activity",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
{
"permissionSet": "crm_sales_user",
"object": "crm_contact",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
{
"permissionSet": "crm_sales_user",
"object": "crm_lead",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
{
"permissionSet": "crm_sales_user",
"object": "crm_opportunity",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
{
"permissionSet": "guest_portal",
"object": "crm_lead",
"create": true,
"read": false,
"edit": false,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
}
]
}
46 changes: 45 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture } from '@objectstack/lint';
import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
Expand Down Expand Up @@ -58,6 +58,10 @@ export default class Compile extends Command {
default: false,
hidden: true,
}),
'update-access-matrix': Flags.boolean({
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.',
default: false,
}),
};

async run(): Promise<void> {
Expand Down Expand Up @@ -357,6 +361,46 @@ export default class Compile extends Command {
}
}

// 3f. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when
// `access-matrix.json` sits next to the config, the (permission set
// × object) capability matrix derived from THIS build must match it
// — a drift fails the build with a SEMANTIC diff ("'crm_admin'
// gains delete on 'crm_lead'") until the snapshot is updated via
// --update-access-matrix. An unchanged matrix auto-passes, so the
// gate costs nothing until someone changes who-can-do-what.
{
const matrixPath = path.join(path.dirname(absolutePath), 'access-matrix.json');
const currentMatrix = buildAccessMatrix(result.data as Record<string, unknown>);
if (flags['update-access-matrix']) {
// Unconditional write — creates or refreshes the snapshot.
fs.writeFileSync(matrixPath, JSON.stringify(currentMatrix, null, 2) + '\n');
if (!flags.json) printStep(`Access matrix snapshot written to ${path.relative(process.cwd(), matrixPath)} (ADR-0090 D6) — review the diff.`);
} else {
// Single read attempt (no exists-then-read TOCTOU): a missing file
// means the app has not opted into the gate; an unreadable/corrupt
// one is treated as empty so the drift report shows every entry.
let committedRaw: string | null = null;
try { committedRaw = fs.readFileSync(matrixPath, 'utf8'); } catch { committedRaw = null; }
if (committedRaw !== null) {
if (!flags.json) printStep('Checking access-matrix snapshot (ADR-0090 D6)...');
let committed: any = { version: 1, entries: [] };
try { committed = JSON.parse(committedRaw); } catch { /* corrupt = empty */ }
const drift = diffAccessMatrix(committed, currentMatrix);
if (drift.length > 0) {
if (flags.json) {
console.log(JSON.stringify({ success: false, error: 'access matrix drift', changes: drift }));
this.exit(1);
}
console.log('');
printError(`Access matrix drift (${drift.length} change${drift.length > 1 ? 's' : ''}) — capability changes must be reviewed`);
for (const line of drift.slice(0, 50)) console.log(` • ${line}`);
console.log(chalk.dim(' If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.'));
this.exit(1);
}
}
}
}

// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
// `docs: DocSchema[]` and lint the combined set (flatness,
// namespace-prefixed names, MDX/image ban, same-package link
Expand Down
78 changes: 78 additions & 0 deletions packages/lint/src/build-access-matrix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// ADR-0090 D6 — access-matrix snapshot: pure build + semantic diff.

import { describe, it, expect } from 'vitest';
import { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';

const STACK = {
objects: [
{ name: 'crm_lead', sharingModel: 'private' },
{ name: 'crm_account', sharingModel: 'public_read' },
],
permissions: [
{
name: 'sales_user',
objects: {
crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, readScope: 'unit' },
crm_account: { allowRead: true },
},
},
{
name: 'crm_admin',
objects: {
crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true },
},
},
],
};

describe('buildAccessMatrix (ADR-0090 D6)', () => {
it('derives one sorted row per (set × object) with OWD context', () => {
const m = buildAccessMatrix(STACK);
expect(m.version).toBe(1);
expect(m.entries.map((e) => `${e.permissionSet}/${e.object}`)).toEqual([
'crm_admin/crm_lead',
'sales_user/crm_account',
'sales_user/crm_lead',
]);
const lead = m.entries.find((e) => e.permissionSet === 'sales_user' && e.object === 'crm_lead')!;
expect(lead).toMatchObject({ create: true, read: true, edit: true, delete: false, readScope: 'unit', sharingModel: 'private' });
// VAMA implies the read bit even without allowRead.
const admin = m.entries.find((e) => e.permissionSet === 'crm_admin')!;
expect(admin.viewAllRecords).toBe(true);
expect(admin.read).toBe(true);
});

it('is deterministic (same input → identical JSON)', () => {
expect(JSON.stringify(buildAccessMatrix(STACK))).toBe(JSON.stringify(buildAccessMatrix(STACK)));
});
});

describe('diffAccessMatrix (semantic review lines)', () => {
it('identical matrices produce no lines', () => {
expect(diffAccessMatrix(buildAccessMatrix(STACK), buildAccessMatrix(STACK))).toEqual([]);
});

it('reports gained bits by name — the crm_admin-gains-delete shape', () => {
const before = buildAccessMatrix(STACK);
const after = buildAccessMatrix(JSON.parse(JSON.stringify(STACK)));
const sales = (after.entries as any[]).find((e) => e.permissionSet === 'sales_user' && e.object === 'crm_lead');
sales.delete = true;
const lines = diffAccessMatrix(before, after);
expect(lines).toEqual(["'sales_user' gains delete on 'crm_lead'"]);
});

it('reports depth changes, entry additions/removals, and OWD swings', () => {
const before = buildAccessMatrix(STACK);
const mutated = JSON.parse(JSON.stringify(STACK));
mutated.permissions[0].objects.crm_lead.readScope = 'org'; // depth widened
delete mutated.permissions[0].objects.crm_account; // entry removed
mutated.permissions[1].objects.crm_account = { allowRead: true }; // entry added
mutated.objects[0].sharingModel = 'public_read_write'; // OWD swing
const lines = diffAccessMatrix(before, buildAccessMatrix(mutated));
expect(lines.some((l) => l.includes("read depth on 'crm_lead': unit → org"))).toBe(true);
expect(lines.some((l) => l.includes("'sales_user' loses ALL access to 'crm_account'"))).toBe(true);
expect(lines.some((l) => l.includes("'crm_admin' gains access to 'crm_account'"))).toBe(true);
expect(lines.some((l) => l.includes('record baseline (OWD): private → public_read_write'))).toBe(true);
});
});
Binary file added packages/lint/src/build-access-matrix.ts
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,5 @@ export {
SECURITY_PRIVATE_NO_READSCOPE,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';

export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
Loading