Skip to content

Commit ef256e6

Browse files
huangyiireneclaude
andauthored
refactor(security): one permission fold — the enforcement door and the access matrix ASK the spec helper (#18785) (#19512)
Fixes #18785 Clause-②: no One rule — "does this effective object permission grant this verb?" — had three implementations. It now has one definition in `@objectstack/spec` (`objectPermissionGrants`) and two consumers that **ask** it: the enforcement door `PermissionEvaluator.checkObjectPermission`, and the access-matrix snapshot `buildAccessMatrix`. ## ⛔ The differential ran FIRST — nothing was converged before it The card's own "Not measured" section named this as the cheap thing to do first, and it forked the rest of the work. It was run against the three implementations **as sources**, before any edit. **Input enumeration, derived rather than hand-listed.** The bit set is read out of the spec's own `EffectiveObjectPermissionSchema.shape` — every key whose unwrapped inner type is `boolean` — which yields exactly the eight declared bits: `allowCreate`, `allowDelete`, `allowEdit`, `allowExport`, `allowRead`, `allowTransfer`, `modifyAllRecords`, `viewAllRecords`. The verb targets come from the spec's own `OBJECT_PERMISSION_VERBS` value range (six). Each bit takes **three** states — `true`, `false`, absent — because every implementation compares with `=== true` and absence is the state an author reaches by omission. That is 3^8 = **6561 inputs**, the full cartesian product over the declared bits, not a sample. ``` bits (derived) : allowCreate, allowDelete, allowEdit, allowExport, allowRead, allowTransfer, modifyAllRecords, viewAllRecords [8] bit states : true | false | absent [3] verb targets : allowCreate, allowDelete, allowEdit, allowExport, allowRead, allowTransfer [6] inputs : 3^8 = 6561 cells spec-vs-evaluator : 39366 cells spec-vs-matrix : 26244 (buildAccessMatrix models 4 of 6 verbs) absent-entry leg : 6 targets, all false = true DISAGREEMENTS spec-vs-evaluator : 0 DISAGREEMENTS spec-vs-matrix : 0 VERDICT differential-disagreements=0 ``` ⇒ **All three agreed on every input.** This is therefore a pure structural convergence and **no behaviour changes** — which is also why no ruling was needed. Had any cell disagreed, the card said stop, and it would have stopped. **The control — the instrument can tell the two cases apart.** "They all returned the same thing" proves nothing unless the harness can *see* a disagreement, so the same run was repeated twice with exactly ONE cell of the spec fold perturbed, once in each direction: | control mutation | what it changes | disagreements found | |:--|:--|:--| | `create-bypass` | "Modify All Data manufactures create" (spec says **true** where the consumers say false) | **2916** (1458 evaluator + 1458 matrix) | | `read-narrow` | read stops bypassing on `modifyAllRecords` (spec says **false** where the consumers say true) | **1944** (972 evaluator + 972 matrix) | Both directions, both consumers, non-zero. Plus a second exhaustiveness leg the enumeration cannot express: `objectPermissionGrants(undefined, verb)` is `false` for all six targets, never a throw. **The evaluator's operation map is checked, not assumed.** `checkObjectPermission` is keyed on ObjectQL operations, not on `allow*` bits, and its `OPERATION_TO_PERMISSION` is module-private. The harness's inverse map is asserted against the exported `crudBucketForOperation` before a single cell is compared, so a drifted mapping aborts the run instead of quietly measuring the wrong pairs. **The fold as the card records it, verified against each implementation** rather than trusted: read bypasses on `viewAllRecords || modifyAllRecords`; write bypasses on `modifyAllRecords` alone and never create; export is `grant ∧ read`. All three held, on all three sides. ## What changed **`packages/plugins/plugin-security/src/permission-evaluator.ts`** — the per-set loop's three inline checks (the modify-all write bypass, the read bypass, the bare bit) collapse to one `objectPermissionGrants(objPerm, permKey)` call. `OPERATION_TO_PERMISSION` is retyped `Record(string, ObjectPermissionVerbTarget)`, which is what makes that call type-safe and forces a future operation's bit to be a verb the spec actually models. **`packages/lint/src/build-access-matrix.ts`** — the four CRUD columns become `objectPermissionGrants(entry, verb)`. The two super-user columns stay **raw bits** on purpose: they report what the set *declares*, which is the context a reviewer reads the CRUD columns against, and folding them would destroy that. **Two things deliberately NOT collapsed**, both documented at the site: - **The export door keeps its cross-set shape.** `checkObjectPermission('export', …)` asks `(∃ set granting export) ∧ (∃ set granting read)` across the whole resolved set list — the same answer the `/me/permissions` most-permissive per-object merge hands the client, and the same value the spec cell returns once that merge has happened. Folding it per set would have **narrowed** the door to `∃ set (export ∧ read)`, denying a caller whose read and export grants arrive from two different sets. A narrowing at the enforcement door is exactly what this card forbids. A pin was added for it. - **`MODIFY_ALL_WRITE_KEYS` is kept and exported, as an assertion rather than a decision.** It no longer decides anything at runtime; it still states, derived from this file's own dispatch vocabulary, *which* bits the write-bypass class contains, and the new test holds the spec's fold to it. Deleting it would have thrown away the #1883 derivation (a future destructive op added to the map+set must make the spec go red, not silently lose its bypass) and dangled the prose in four other files that name it. `packages/lint/src/index.ts` was **not touched** — `buildAccessMatrix` was already exported, and the change is an import, not an export edit. ## Following #17469, and where this departs #17469's landing is the model: `driver-sql`'s raw `field.multiple` reads became one call onto the spec's `isMultiValueField`, behind a small documented adapter (`isMultiValuedColumn`) whose docblock names the ruling and says in one line that there is ONE definition. **Followed** — one definition in the spec, consumers ask it, and each call site carries a docblock that says what restating it would cost rather than just what it does. **Departed, three ways, each on purpose:** 1. **No local adapter.** #17469 needed one because its callers had already applied their own `type` resolution and two spellings of that default is how its drift started. These consumers hold the effective entry itself, so the call is direct and an adapter would be a third spelling with nothing to reconcile. 2. **No behaviour change and no ruling.** #17469 converged a **live divergence**, so a maintainer had to say which predicate was authoritative. Here the differential above proved agreement before anything moved, so there was nothing to rule on — and per the card, if it had disagreed this PR would not exist. 3. **No authoring-entrance half.** #17469 also tightened `FieldSchema` to refuse the flag it had been accepting. Nothing here needed a spec edit, and the dispatch is explicit that one would be a different lane. ## Ablation — each consumer proved to be ASKING, not agreeing by coincidence The pins were written **independently of the spec helper**, from the rule itself. Asserting `consumer === objectPermissionGrants(...)` would have been a tautology: both sides move together and the pin survives any change to the fold, including a wrong one. Because they restate the rule instead, one changed cell in the spec reddens them. One `scripts/ablation-replace.mjs` process: mutate → rebuild spec → prove the mutation reached `dist/` → run each consumer → restore, with the restore proven on disk. ``` ablation-replace: anchor "case 'allowEdit': return permission.allowEdit === true || modifyAll;" x1 -> x0 ablation-replace: blob 0e6d690 -> ba220b986417 ablation-dist-preflight: marker present in 2 built files -- the ablation is live in the artifact the suite consumes hit packages/spec/dist/security/index.js hit packages/spec/dist/security/index.mjs LINT-CONSUMER exit=1 Test Files 1 failed (1) Tests 2 failed | 6 passed (8) SECURITY-CONSUMER exit=1 Test Files 1 failed (1) Tests 3 failed | 2 passed (5) ablation-replace: ok restored: blob == HEAD (0e6d690) and `git diff HEAD` is empty ablation-dist-preflight --absent: marker absent from all 216 built files ablation-dist-preflight --absent: tree clean against HEAD -- nothing of this ablation is recorded outside dist/ git status --porcelain (whole tree) : empty post-restore rerun : lint 8 passed (8) · plugin-security 5 passed (5) ``` The ablation cell was `allowEdit`'s write bypass — one cell, and **both** consumers went red through it. Named failures: - lint: `named cells: the fold as the rule states it`, `exhaustively matches the fold over every declared bit combination` - plugin-security: the same two, plus `the Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch map` ⚠️ The restore leg was rebuilt as well as reverted: a marker left in `dist/` keeps mutated code live for every later run in the worktree, and `--absent` is what proves it is gone. ## The enforcement door is security-relevant — every covering test file, before and after `checkObjectPermission` is exercised by exactly five test files, all in `@objectstack/plugin-security`. Three other files name it **in prose only** and call nothing — `packages/spec/src/security/permission.test.ts`, `packages/formula/src/permission-predicate.test.ts`, `packages/runtime/src/domains/share-links-enforcement-context.test.ts` — so they are listed for completeness, not counted. | test file(s) | before | after | |:--|--:|--:| | the four pre-existing door files, run as one vitest invocation — `admin-export-wildcard.test.ts`, `export-permission-axis.test.ts`, `member-default-explicit-allow.test.ts`, `security-plugin.test.ts` | **334 passed** | **334 passed** | | `plugin-security/src/permission-evaluator.test.ts` — NEW, the file the door never had | — | **5 passed** | | **door total** | **334** | **339** | | `lint/src/build-access-matrix.test.ts` (the matrix consumer) | **5 passed** | **8 passed** | "Before" is a measurement, not an inference: the two source files were reverted to the merge base and the new test file removed, the four door files were run against that tree, and the revert was undone with `git checkout HEAD --` and proved byte-exact (`git status --porcelain` empty, `git diff HEAD` empty, and each of the four paths' `git hash-object` equal to its blob at `HEAD`). **Nothing was removed or weakened** — every pre-existing case is still there and still green. ## Verification Measured at `a5e7c92e8`, base `4045781fa`. | what | result | |:--|:--| | `pnpm --filter @objectstack/lint run test` | **4022 passed (4022)**, 106 files | | `pnpm --filter @objectstack/plugin-security run test` | **2249 passed (2249)**, 117 files | | `pnpm --filter @objectstack/lint run typecheck` | exit **0** (incl. test layer: 2 files / 6 errors / 2 pinned signatures held, unchanged) | | `pnpm --filter @objectstack/plugin-security run typecheck` | exit **0** (incl. test layer: 0 / 0 / 0) | | `pnpm lint` (repo-wide eslint, the union — not a narrowing) | exit **0** | **Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, every exit code captured **before any pipe**, reconciled with `--ran`: ``` Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3). ``` Three of them first answered **exit 3 — PREREQUISITE NOT MET**, which is neither a pass nor a fail. All three named the same prerequisite — no built output on disk — and it was **discharged**, not reported around: | gate | prerequisite it named | after `turbo run build` over all packages | |:--|:--|:--| | `pnpm check:dual-build-cjs-loads` | 53 packages have no `dist/` | exit **0** | | `pnpm check:i18n` | the CLI plus the build closure of 9 extract-config packages | exit **0** | | `pnpm check:type-check-debt` | 14 workspace deps of ledgered packages have no built type entry point | exit **0** | The derivation was re-run after a fresh `git fetch origin main` (which moved `origin/main` 4 commits): the family list came back **byte-identical**, so nothing new was owed. ## Changeset — measured, not assumed `skip-changeset` does not apply. Both packages publish, and the changed bytes reach `dist`: | | `private` | `files[]` | changed bytes reach `dist`? | |:--|:--|:--|:--| | `@objectstack/lint` | unset (public) | `dist`, `README.md`, `CHANGELOG.md` | yes — `src/build-access-matrix.ts` is built into `dist/index.js` / `.cjs` | | `@objectstack/plugin-security` | unset (public) | `dist`, `README.md`, `CHANGELOG.md` | yes — `src/permission-evaluator.ts` likewise | ⇒ `.changeset/18785-one-permission-fold.md`, **`patch` for both**. Not breaking: no export is removed or renamed, no authorable key changes meaning, and the differential is the evidence that no answer moves. `@objectstack/spec/security`'s `./security` subpath carries both an `import` and a `require` condition, so the new value import is safe on the CJS side of both dual builds — `check:dual-build-cjs-loads` confirms it against real emitted bytes. ## Acceptance notes ⛔ No cards filed from here — the seat files everything. **① A fourth site reads the same bits, and its own docblock claims to mirror this door — measured, they differ on one bit.** The card's second "Not measured" bullet asked whether any consumer outside these three folds the same bits a fourth time. It does. `packages/lint/src/validate-security-posture.ts` has `grantsObjectAccess`, whose docblock says verbatim: > Any of the four CRUD bits, or a super-user bypass (View/Modify All Data), counts — this mirrors the runtime `checkObjectPermission` gate (ADR-0066 D2): that gate returns true if ANY set contributes one of these for the object. Its disjunction omits `allowTransfer`. Reproduced today, on this branch: ``` evaluator: checkObjectPermission('transfer', 'crm_line') with objects.crm_line = { allowTransfer: true } -> true lint : security-master-detail-ungranted — 'detail object "crm_line" … has no object-level CRUD grant in any permission set' ``` `allowTransfer` is authorable and enforced today through the insert/update `owner_id` door, so the shape is reachable. ⚠️ Whether this is a **defect** turns on whether "object-level CRUD" is meant to include the transfer bit — that is a ruling about the lint rule's intent, ⛔ not a refactor, and the docblock's mirror claim is the part that is measurably false either way. Proposed class **(b)** — a declared claim the measurement contradicts. `Seam: spec:allowTransfer → runtime:packages/lint/src/validate-security-posture.ts#grantsObjectAccess`. Severity is advisory only: the rule emits `warning` and does not gate the build. ⛔ Not touched here — outside this card's file surface, and that exact file is held by open PR #19486. **Carrier: PR #19486**, which already edits `validate-security-posture.ts`. Dedupe words: `grantsObjectAccess` · `allowTransfer` · `security-master-detail-ungranted` · `validate-security-posture` · `object-level CRUD`. **② The wildcard-super-user question is stated twice, and both agree — noted, not filed.** `resolveObjectPermission` (plugin-security) and `wildcardGrantsSuperRead` (`packages/plugins/plugin-hono-server/src/current-user-endpoints.ts`) both decide "does the `'*'` entry carry the super-user read bypass". That is a **different** question from this card's verb fold, the hono-server copy already documents itself as the single reading for its own file and cites the evaluator, and they agree today. Recorded as the boundary of what this card converged, ⛔ not widened into it. Carrier: none (承接者:无). **③ Three sites in `permission-evaluator.ts` still read `viewAllRecords || modifyAllRecords` inline, and correctly so.** `getEffectiveScope` and `getDeclaredScope` answer the **depth** axis, and `superuserBypassSets` answers "is the bypass bit held" — already the one function every bypass consumer folds through, per #4647. None of them answers "does this entry grant this verb", so none was converged. Stated so the next reader does not mistake the omission for an oversight. Carrier: none (承接者:无). **④ A stale code quotation in a file outside this surface — noted, not filed.** `packages/spec/src/security/permission.test.ts` line 1172 quotes the evaluator's now-removed inline expression `permKey === 'allowRead' && …` in a comment. Prose drift only; the assertions around it are unaffected and still green. ⛔ Not a defect class. Carrier: none (承接者:无). **⑤ On the recorded affinity with #18783** — the dispatch asked to say so if I think they are one card. **I do not.** Triage's reading holds up from inside the work: this card needed a *measurement* and could be finished the moment the differential came back zero; #18783 is waiting on a *decision*. Merging them would have parked a finished measurement behind an open question. #18783 remains open and untouched. ⛔ Merging cards is triage's call in any case. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d76facf commit ef256e6

5 files changed

Lines changed: 372 additions & 22 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/lint": patch
3+
"@objectstack/plugin-security": patch
4+
---
5+
6+
`PermissionEvaluator.checkObjectPermission` and `buildAccessMatrix` now ASK `@objectstack/spec`'s `objectPermissionGrants` instead of restating the super-user fold — one rule, one definition (#18785).
7+
8+
"Does this effective object permission grant this verb?" had three independent implementations: the spec helper published in 17.4, the enforcement door in `@objectstack/plugin-security`, and the access-matrix snapshot in `@objectstack/lint`. A differential over the full input space — every declared object-permission bit (`allowCreate` / `allowRead` / `allowEdit` / `allowDelete` / `allowTransfer` / `allowExport` / `viewAllRecords` / `modifyAllRecords`) in all three authorable states, 6561 entries by 6 verbs — found **zero** disagreements, so this is a structural convergence and **no behaviour changes**.
9+
10+
- **No API change, no bit changes meaning.** The read bypass is still `viewAllRecords || modifyAllRecords`, the write bypass is still `modifyAllRecords` alone, `allowCreate` still has no super-user bypass, and `export` is still `grant ∧ read`.
11+
- **The export door keeps its cross-set shape.** `checkObjectPermission('export', …)` still asks `(∃ set granting export) ∧ (∃ set granting read)` across the resolved set list — the same answer the `/me/permissions` most-permissive merge hands the client. Folding it per set would have narrowed the door.
12+
- **Both consumers are pinned to the fold independently of the helper**, so a change to one cell of `objectPermissionGrants` reddens them rather than propagating silently.

‎packages/lint/src/build-access-matrix.test.ts‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,123 @@ describe('diffAccessMatrix (semantic review lines)', () => {
7676
expect(lines.some((l) => l.includes('record baseline (OWD): private → public_read_write'))).toBe(true);
7777
});
7878
});
79+
80+
/*
81+
* [#18785] The CRUD columns are the SPEC's fold, asked — this is the pin that
82+
* says so.
83+
*
84+
* `buildAccessMatrix` used to restate the super-user fold inline. The rule —
85+
* "does this effective object permission grant this verb?" — is stated once in
86+
* `@objectstack/spec`'s `objectPermissionGrants`, and the enforcement door
87+
* (`PermissionEvaluator.checkObjectPermission`) asks the same function, so the
88+
* snapshot a human signs off cannot drift away from the 403 the server hands
89+
* out.
90+
*
91+
* ⚠️ The expectation below is written INDEPENDENTLY of the spec helper, from
92+
* the rule as the card states it: read bypasses on `viewAllRecords ||
93+
* modifyAllRecords`; write bypasses on `modifyAllRecords` alone and NEVER
94+
* create. Comparing the matrix against `objectPermissionGrants` instead would
95+
* be a tautology — both sides would move together and the pin would survive any
96+
* change to the fold. This way an ablation of one spec cell reddens it.
97+
*
98+
* The enumeration is exhaustive over the declared object-permission bits, in
99+
* all three authorable states (`true` / `false` / absent), because every
100+
* implementation compares with `=== true` and absence is what an author
101+
* produces by omission.
102+
*/
103+
const FOLD_BITS = [
104+
'allowCreate', 'allowDelete', 'allowEdit', 'allowExport',
105+
'allowRead', 'allowTransfer', 'modifyAllRecords', 'viewAllRecords',
106+
] as const;
107+
const BIT_STATES: Array<boolean | undefined> = [true, false, undefined];
108+
109+
/** The fold, restated from the rule — deliberately NOT the spec helper. */
110+
function referenceFold(p: Record<string, unknown>, verb: 'create' | 'read' | 'edit' | 'delete'): boolean {
111+
const modifyAll = p.modifyAllRecords === true;
112+
switch (verb) {
113+
case 'create': return p.allowCreate === true;
114+
case 'read': return p.allowRead === true || p.viewAllRecords === true || modifyAll;
115+
case 'edit': return p.allowEdit === true || modifyAll;
116+
case 'delete': return p.allowDelete === true || modifyAll;
117+
}
118+
}
119+
120+
function enumerateEntries(): Array<Record<string, unknown>> {
121+
const out: Array<Record<string, unknown>> = [];
122+
const total = BIT_STATES.length ** FOLD_BITS.length;
123+
for (let i = 0; i < total; i++) {
124+
const entry: Record<string, unknown> = {};
125+
let n = i;
126+
for (const bit of FOLD_BITS) {
127+
const s = BIT_STATES[n % BIT_STATES.length];
128+
n = Math.floor(n / BIT_STATES.length);
129+
if (s !== undefined) entry[bit] = s;
130+
}
131+
out.push(entry);
132+
}
133+
return out;
134+
}
135+
136+
describe('buildAccessMatrix CRUD columns — the spec fold, asked (#18785)', () => {
137+
it('named cells: the fold as the rule states it', () => {
138+
const m = buildAccessMatrix({
139+
objects: [{ name: 'o' }],
140+
permissions: [
141+
{ name: 'vad', objects: { o: { viewAllRecords: true } } },
142+
{ name: 'mad', objects: { o: { modifyAllRecords: true } } },
143+
{ name: 'none', objects: { o: {} } },
144+
],
145+
});
146+
const row = (ps: string) => m.entries.find((e) => e.permissionSet === ps)!;
147+
// READ bypasses on either super-user bit.
148+
expect(row('vad').read).toBe(true);
149+
expect(row('mad').read).toBe(true);
150+
// WRITE bypasses on Modify All Data ALONE — View All Data is a read power.
151+
expect(row('mad').edit).toBe(true);
152+
expect(row('mad').delete).toBe(true);
153+
expect(row('vad').edit).toBe(false);
154+
expect(row('vad').delete).toBe(false);
155+
// CREATE is never manufactured by a super-user bit.
156+
expect(row('mad').create).toBe(false);
157+
expect(row('vad').create).toBe(false);
158+
// An entry with no bits grants nothing.
159+
expect(row('none')).toMatchObject({ create: false, read: false, edit: false, delete: false });
160+
});
161+
162+
it('exhaustively matches the fold over every declared bit combination', () => {
163+
const entries = enumerateEntries();
164+
expect(entries.length).toBe(3 ** 8);
165+
const m = buildAccessMatrix({
166+
objects: [{ name: 'o' }],
167+
permissions: entries.map((e, i) => ({ name: `ps_${String(i).padStart(4, '0')}`, objects: { o: e } })),
168+
});
169+
expect(m.entries.length).toBe(entries.length);
170+
const mismatches: string[] = [];
171+
let cells = 0;
172+
for (let i = 0; i < entries.length; i++) {
173+
const row = m.entries.find((e) => e.permissionSet === `ps_${String(i).padStart(4, '0')}`)!;
174+
for (const verb of ['create', 'read', 'edit', 'delete'] as const) {
175+
cells++;
176+
if (row[verb] !== referenceFold(entries[i], verb)) {
177+
mismatches.push(`${JSON.stringify(entries[i])} × ${verb}: matrix=${row[verb]} rule=${referenceFold(entries[i], verb)}`);
178+
}
179+
}
180+
}
181+
expect(cells).toBe(3 ** 8 * 4);
182+
expect(mismatches).toEqual([]);
183+
});
184+
185+
it('the super-user columns stay RAW BITS, not folds', () => {
186+
const m = buildAccessMatrix({
187+
objects: [{ name: 'o' }],
188+
permissions: [{ name: 'mad', objects: { o: { modifyAllRecords: true } } }],
189+
});
190+
const row = m.entries[0];
191+
// Modify All Data implies the read POWER, but it is not a declaration of
192+
// View All Data — the reviewer reads the CRUD columns against what the set
193+
// actually declares.
194+
expect(row.modifyAllRecords).toBe(true);
195+
expect(row.viewAllRecords).toBe(false);
196+
expect(row.read).toBe(true);
197+
});
198+
});

‎packages/lint/src/build-access-matrix.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
* AI may draft grants freely; it cannot silently change who can do what.
1616
*/
1717

18-
import type { AccessMatrixParsed, AccessMatrixEntry } from '@objectstack/spec/security';
18+
import type {
19+
AccessMatrixParsed,
20+
AccessMatrixEntry,
21+
EffectiveObjectPermission,
22+
} from '@objectstack/spec/security';
23+
import { objectPermissionGrants } from '@objectstack/spec/security';
1924
import { recordsOf } from './object-graph.js';
2025

2126
type AnyRec = Record<string, unknown>;
@@ -39,13 +44,25 @@ export function buildAccessMatrix(stack: AnyRec): AccessMatrixParsed {
3944
const objects = (ps.objects && typeof ps.objects === 'object' ? ps.objects : {}) as AnyRec;
4045
for (const [objName, rawPerm] of Object.entries(objects)) {
4146
const p = (rawPerm ?? {}) as AnyRec;
47+
// [#18785] The CRUD bits are the SPEC's fold, asked — never restated.
48+
// `objectPermissionGrants` is the one definition of "does this effective
49+
// object permission grant this verb?", and the enforcement door
50+
// (`PermissionEvaluator.checkObjectPermission`) asks the same function.
51+
// A matrix restating the fold inline is a second implementation of a
52+
// security rule whose whole job is to be reviewable: it would keep
53+
// answering the old way for a full release after the door changed, and
54+
// the snapshot diff — the artefact a human signs off — would say nothing.
55+
// The two super-user columns below are RAW BITS, not folds: they report
56+
// what the set declares, which is the context the reviewer reads the CRUD
57+
// columns against.
58+
const effective = p as EffectiveObjectPermission;
4259
const entry: AccessMatrixEntry = {
4360
permissionSet: psName,
4461
object: objName,
45-
create: p.allowCreate === true,
46-
read: p.allowRead === true || p.viewAllRecords === true || p.modifyAllRecords === true,
47-
edit: p.allowEdit === true || p.modifyAllRecords === true,
48-
delete: p.allowDelete === true || p.modifyAllRecords === true,
62+
create: objectPermissionGrants(effective, 'allowCreate'),
63+
read: objectPermissionGrants(effective, 'allowRead'),
64+
edit: objectPermissionGrants(effective, 'allowEdit'),
65+
delete: objectPermissionGrants(effective, 'allowDelete'),
4966
viewAllRecords: p.viewAllRecords === true,
5067
modifyAllRecords: p.modifyAllRecords === true,
5168
};
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18785] The enforcement door asks ONE fold.
5+
*
6+
* "Does this effective object permission grant this verb?" is stated once, in
7+
* `@objectstack/spec`'s `objectPermissionGrants`.
8+
* `PermissionEvaluator.checkObjectPermission` — the fold the server actually
9+
* applies — asks that function instead of restating it, and so does
10+
* `@objectstack/lint`'s `buildAccessMatrix`. Before this card the rule had
11+
* three implementations that happened to agree; agreeing today is not a
12+
* property a security rule can be left resting on.
13+
*
14+
* ⚠️ The expectation here is written INDEPENDENTLY of the spec helper, from the
15+
* rule itself: read bypasses on `viewAllRecords || modifyAllRecords`; write
16+
* bypasses on `modifyAllRecords` ALONE and never create; export is
17+
* `grant ∧ read`. Asserting `checkObjectPermission === objectPermissionGrants`
18+
* would be a tautology — both sides move together, and the pin would survive
19+
* any change to the fold, including a wrong one. Stated this way, changing one
20+
* cell of the spec helper reddens this file, which is the only evidence that
21+
* the door is really asking it.
22+
*
23+
* The enumeration is exhaustive over the declared object-permission bits in all
24+
* three authorable states (`true` / `false` / absent): every implementation
25+
* compares with `=== true`, and absence is the state an author reaches by
26+
* omission.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import type { PermissionSet } from '@objectstack/spec/security';
31+
import { PermissionEvaluator, MODIFY_ALL_WRITE_KEYS } from './permission-evaluator';
32+
33+
const evaluator = new PermissionEvaluator();
34+
const OBJ = 'deal';
35+
36+
const set = (name: string, objects: Record<string, unknown>): PermissionSet =>
37+
({ name, objects } as unknown as PermissionSet);
38+
39+
/** The ObjectQL operation that asks each `allow*` bit at the door. */
40+
const OPERATION_FOR: Record<Verb, string> = {
41+
allowRead: 'find',
42+
allowCreate: 'insert',
43+
allowEdit: 'update',
44+
allowDelete: 'delete',
45+
allowTransfer: 'transfer',
46+
allowExport: 'export',
47+
};
48+
49+
type Verb = 'allowRead' | 'allowCreate' | 'allowEdit' | 'allowDelete' | 'allowTransfer' | 'allowExport';
50+
const VERBS: Verb[] = ['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer', 'allowExport'];
51+
52+
const FOLD_BITS = [
53+
'allowCreate', 'allowDelete', 'allowEdit', 'allowExport',
54+
'allowRead', 'allowTransfer', 'modifyAllRecords', 'viewAllRecords',
55+
] as const;
56+
const BIT_STATES: Array<boolean | undefined> = [true, false, undefined];
57+
58+
/** The fold, restated from the rule — deliberately NOT the spec helper. */
59+
function referenceFold(p: Record<string, unknown>, verb: Verb): boolean {
60+
const modifyAll = p.modifyAllRecords === true;
61+
const read = p.allowRead === true || p.viewAllRecords === true || modifyAll;
62+
switch (verb) {
63+
case 'allowRead': return read;
64+
case 'allowCreate': return p.allowCreate === true;
65+
case 'allowEdit': return p.allowEdit === true || modifyAll;
66+
case 'allowDelete': return p.allowDelete === true || modifyAll;
67+
case 'allowTransfer': return p.allowTransfer === true || modifyAll;
68+
case 'allowExport': return p.allowExport === true && read;
69+
}
70+
}
71+
72+
function enumerateEntries(): Array<Record<string, unknown>> {
73+
const out: Array<Record<string, unknown>> = [];
74+
const total = BIT_STATES.length ** FOLD_BITS.length;
75+
for (let i = 0; i < total; i++) {
76+
const entry: Record<string, unknown> = {};
77+
let n = i;
78+
for (const bit of FOLD_BITS) {
79+
const s = BIT_STATES[n % BIT_STATES.length];
80+
n = Math.floor(n / BIT_STATES.length);
81+
if (s !== undefined) entry[bit] = s;
82+
}
83+
out.push(entry);
84+
}
85+
return out;
86+
}
87+
88+
describe('checkObjectPermission — the one fold, asked (#18785)', () => {
89+
it('named cells: the fold as the rule states it', () => {
90+
const ask = (entry: Record<string, unknown>, verb: Verb) =>
91+
evaluator.checkObjectPermission(OPERATION_FOR[verb], OBJ, [set('ps', { [OBJ]: entry })]);
92+
93+
// READ bypasses on EITHER super-user bit (Modify All implies View All).
94+
expect(ask({ viewAllRecords: true }, 'allowRead')).toBe(true);
95+
expect(ask({ modifyAllRecords: true }, 'allowRead')).toBe(true);
96+
97+
// WRITE bypasses on Modify All Data ALONE — View All Data is a read power
98+
// and must never widen a write.
99+
expect(ask({ modifyAllRecords: true }, 'allowEdit')).toBe(true);
100+
expect(ask({ modifyAllRecords: true }, 'allowDelete')).toBe(true);
101+
expect(ask({ modifyAllRecords: true }, 'allowTransfer')).toBe(true);
102+
expect(ask({ viewAllRecords: true }, 'allowEdit')).toBe(false);
103+
expect(ask({ viewAllRecords: true }, 'allowDelete')).toBe(false);
104+
expect(ask({ viewAllRecords: true }, 'allowTransfer')).toBe(false);
105+
106+
// CREATE is never manufactured by a super-user bit.
107+
expect(ask({ modifyAllRecords: true }, 'allowCreate')).toBe(false);
108+
expect(ask({ viewAllRecords: true }, 'allowCreate')).toBe(false);
109+
expect(ask({ allowCreate: true }, 'allowCreate')).toBe(true);
110+
111+
// EXPORT is a CONJUNCTION: the grant alone grants nothing, and the
112+
// super-user bits satisfy the read half without implying export.
113+
expect(ask({ allowExport: true }, 'allowExport')).toBe(false);
114+
expect(ask({ allowExport: true, allowRead: true }, 'allowExport')).toBe(true);
115+
expect(ask({ allowExport: true, viewAllRecords: true }, 'allowExport')).toBe(true);
116+
expect(ask({ modifyAllRecords: true }, 'allowExport')).toBe(false);
117+
118+
// An entry with no bits grants nothing at all.
119+
for (const verb of VERBS) expect(ask({}, verb)).toBe(false);
120+
});
121+
122+
it('exhaustively matches the fold over every declared bit combination', () => {
123+
const entries = enumerateEntries();
124+
expect(entries.length).toBe(3 ** 8);
125+
const mismatches: string[] = [];
126+
let cells = 0;
127+
for (const entry of entries) {
128+
const sets = [set('ps', { [OBJ]: entry })];
129+
for (const verb of VERBS) {
130+
cells++;
131+
const got = evaluator.checkObjectPermission(OPERATION_FOR[verb], OBJ, sets);
132+
const want = referenceFold(entry, verb);
133+
if (got !== want) mismatches.push(`${JSON.stringify(entry)} × ${verb}: door=${got} rule=${want}`);
134+
}
135+
}
136+
expect(cells).toBe(3 ** 8 * VERBS.length);
137+
expect(mismatches).toEqual([]);
138+
});
139+
140+
it('the Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch map', () => {
141+
// MODIFY_ALL_WRITE_KEYS no longer decides anything — it states, on this
142+
// side, which bits the class contains, derived from OPERATION_TO_PERMISSION
143+
// and DESTRUCTIVE_OPERATIONS. Holding the fold to it is what makes a future
144+
// destructive operation added to the map go red in the spec instead of
145+
// silently losing its bypass (#1883).
146+
expect([...MODIFY_ALL_WRITE_KEYS].sort()).toEqual(['allowDelete', 'allowEdit', 'allowTransfer']);
147+
const mad = { modifyAllRecords: true };
148+
for (const key of MODIFY_ALL_WRITE_KEYS) {
149+
expect(evaluator.checkObjectPermission(OPERATION_FOR[key as Verb], OBJ, [set('ps', { [OBJ]: mad })])).toBe(true);
150+
}
151+
for (const verb of VERBS.filter((v) => !MODIFY_ALL_WRITE_KEYS.has(v as never) && v !== 'allowRead')) {
152+
expect(evaluator.checkObjectPermission(OPERATION_FOR[verb], OBJ, [set('ps', { [OBJ]: mad })])).toBe(false);
153+
}
154+
});
155+
156+
it('export stays a CROSS-SET conjunction — the two halves may arrive from different sets', () => {
157+
// The spec cell is `grant ∧ read` over ONE effective entry; this door asks
158+
// it over the whole resolved set list, which is what the `/me/permissions`
159+
// most-permissive per-object merge hands the client. Collapsing it per set
160+
// would deny this caller.
161+
const reader = set('reader', { [OBJ]: { allowRead: true } });
162+
const exporter = set('exporter', { [OBJ]: { allowExport: true } });
163+
expect(evaluator.checkObjectPermission('export', OBJ, [reader, exporter])).toBe(true);
164+
expect(evaluator.checkObjectPermission('export', OBJ, [exporter, reader])).toBe(true);
165+
expect(evaluator.checkObjectPermission('export', OBJ, [exporter])).toBe(false);
166+
expect(evaluator.checkObjectPermission('export', OBJ, [reader])).toBe(false);
167+
});
168+
169+
it('the unmapped-operation posture is untouched by the convergence', () => {
170+
const grant = set('ps', { [OBJ]: { allowRead: true, allowEdit: true, modifyAllRecords: true } });
171+
// Destructive but unmapped ⇒ denied unconditionally, bypass never consulted.
172+
expect(evaluator.checkObjectPermission('restore', OBJ, [grant])).toBe(false);
173+
expect(evaluator.checkObjectPermission('purge', OBJ, [grant])).toBe(false);
174+
// Non-destructive unknown ⇒ default-allow, as before.
175+
expect(evaluator.checkObjectPermission('customReadSideOp', OBJ, [grant])).toBe(true);
176+
});
177+
});

0 commit comments

Comments
 (0)