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
60 changes: 60 additions & 0 deletions .changeset/authz-proof-attribution.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
"@objectstack/verify": patch
---

fix(verify): a conformance ledger's `proof` must NAME the row it proves, not merely exist (#7976)

`checkLedger` asserted exactly one thing about a `proof`: that the file is on
disk (`existsSync(join(proofRoot, r.proof))`). Nothing ever read it. So a row
could cite a test that exercises a **neighbouring** primitive and stay green
forever — and the ADR-0056 D10 authz matrix is where that bites, because it is
the artifact reviewers consult *instead of* re-deriving the audit by hand. Not
hypothetical bookkeeping: `rls-read` and `rls-by-id-write` cite the **same**
file, and until PR #7975 read it line by line nothing could tell whether it
exercised one, the other, or both. That answer cost a manual read of two proof
files and a live ablation; no gate could have produced it either way.

"Does this test actually prove this row" is **not** mechanically decidable, and
this does not attempt it — no heuristic, no coverage inference. It converts the
undecidable question into a checkable one: **the proof file names the rows it is
the proof for, and the pairing must be MUTUAL.**

`CheckLedgerOptions.attribution` (opt-in; every existing ledger is unchanged
until it opts in) takes a marker keyword — the authz matrix uses `authz-row` —
which proof files declare in their header, one line per row:

```ts
// authz-row: rls-read
// authz-row: rls-by-id-write
```

Both directions are then asserted:

1. every row's cited proof file **claims that row's id**, and
2. every claim is **reciprocated** — the claimed id is a real ledger row, and
that row cites this very file.

Direction 2 is why the option also takes a `scan`: a claim sitting in a file no
row cites is invisible to the citation walk by construction, which is exactly
what a renamed row or a re-pointed proof leaves behind.

A **comment marker** rather than an exported manifest is deliberate. Proof files
are test modules whose import registers — and can boot — real stacks, so a claim
has to be readable without executing them; this is the same `readFileSync` the
existence check already implied. It also mirrors the `@proof:` header idiom
dogfood proofs already carry for the ADR-0054 liveness registry, while keeping a
**separate keyword on purpose**: liveness proof ids and matrix row ids are
different vocabularies (one file is `@proof: cbp-controlled-by-parent` *and*
`authz-row: controlled-by-parent`), and collapsing them would let one gate's
rename silently re-point the other's.

All 24 authz rows that cite a proof were annotated by **reading the cited file**,
never by pattern-matching names. One citation did not survive that read and was
dropped rather than rubber-stamped: `requireAuth-removed` cited
`showcase-anonymous-deny.dogfood.test.ts`, which drives the platform default and
observes 401 — precisely what the `anonymous-deny` row (the same file) already
claims. It never authors `requireAuth: false`, never reads the spec tombstone
and never boots an auth-less stack, so it cannot prove this row's distinguishing
half: that there is **no opt-out**. The row keeps `state: 'enforced'` on its
unchanged enforcement site; it simply stops borrowing a sibling's credibility,
and its note now says where the retirement really is pinned.
6 changes: 5 additions & 1 deletion content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -360,7 +360,11 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
ADR-0056 D10): every authorization primitive sits in exactly one honest
state — `enforced` (must name its enforcement site; high-risk rows must
reference an end-to-end dogfood proof), `experimental`, or `removed`. A new
fail-open or a deleted proof fails CI.
fail-open or a deleted proof fails CI. A cited proof must also **name the rows
it proves** — a header `// authz-row: <id>` line — and CI asserts the pairing
is mutual in both directions (#7976), so a row cannot cite a test that
exercises a neighbouring primitive, and a shared proof file has to say which
rows it covers.
- **Liveness ledger** (`packages/spec/liveness/`, ADR-0049/0054): every
governed spec property is classified live / experimental / dead, with
author-time warnings for declared-but-unenforced flags.
Expand Down
24 changes: 18 additions & 6 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,10 +6,23 @@
// primitive, each in EXACTLY ONE honest state (enforced / experimental /
// removed). `enforced` rows name their runtime enforcement site; high-risk
// enforced rows additionally reference an end-to-end dogfood proof. The
// companion test (`authz-conformance.test.ts`) asserts the matrix is complete
// and that every referenced proof file exists — so "the permission model is
// landed" is a CHECKED artifact, not a one-time scan. A new fail-open (a
// declared-but-unenforced primitive) or a deleted proof breaks CI.
// companion test (`authz-conformance.test.ts`) asserts the matrix is complete,
// that every referenced proof file exists, AND that the row ↔ proof pairing is
// MUTUAL — so "the permission model is landed" is a CHECKED artifact, not a
// one-time scan. A new fail-open (a declared-but-unenforced primitive) or a
// deleted proof breaks CI.
//
// [#7976] Existence used to be the whole `proof` contract, which meant a row
// could cite a file exercising a NEIGHBOURING primitive and stay green forever:
// `rls-read` and `rls-by-id-write` cite the same file, and until PR #7975
// nothing could tell whether it exercised one, the other, or both. "Does this
// test prove this row" is not mechanically decidable and is deliberately NOT
// attempted. The checkable question it is converted into: each proof file NAMES
// the rows it is the proof for (a header `// authz-row: <id>` line), and the
// checker asserts both directions — a cited file must claim the citing row, and
// every claim must be reciprocated by the ledger. A shared proof file therefore
// has to SAY which rows it covers, and a row whose proof will not claim it
// loses the citation out loud instead of borrowing a sibling's credibility.

export type AuthzState = 'enforced' | 'experimental' | 'removed';

Expand DownExpand Up@@ -216,8 +229,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
note: 'REMOVED from spec (rls.zod.ts — RLSConfigSchema/RLSAuditEventSchema/RLSAuditConfigSchema deleted). The enforced RLS path (plugin-security computeRlsFilter) never read them; per-policy RowLevelSecurityPolicySchema is the live surface and is unchanged.' },
{ id: 'requireAuth-removed', summary: 'anonymous access to object data is denied unconditionally (no opt-out)', state: 'enforced',
enforcement: 'core/security/anonymous-deny.ts shouldDenyAnonymous — no `requireAuth` input; every seam denies an anonymous, non-system caller outside the control-plane allowlist. spec tombstones `api.requireAuth` (retiredKey).',
proof: 'showcase-anonymous-deny.dogfood.test.ts',
note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open.' },
note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open. [#7976] The `showcase-anonymous-deny.dogfood.test.ts` CITATION WAS DROPPED under mutual attribution — that file drives the platform default and observes 401, which is precisely what the `anonymous-deny` row (same file) already claims; it never authors `requireAuth: false`, never reads the spec tombstone and never boots an auth-less stack, so it cannot prove the distinguishing half of THIS row (that there is no opt-out). The retirement is pinned elsewhere and unit-side: the spec tombstone + the ADR-0087 conversion entry `stack.api.requireAuth` (conversions/registry.ts, which strips a surviving key) and rest/rest-auth-gate.test.ts. Not high-risk, so the row is sound without a dogfood proof; writing a real one (author `api: { requireAuth: false }` → expect the boot/authoring rejection) is the honest upgrade path, not re-citing the posture proof.' },

// ── Removed — by ADR-0049 (roadmap M2) ─────────────────────────────────
{ id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed',
Expand Down
93 changes: 91 additions & 2 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,14 +15,29 @@
import { describe, expect, it } from 'vitest';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { checkLedger } from '@objectstack/verify';
import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js';

const HERE = dirname(fileURLToPath(import.meta.url));
// packages/qa/dogfood/test → repo root.
const REPO_ROOT = join(HERE, '../../../..');

// ── #7976 — mutual row ↔ proof attribution ────────────────────────────────
// A proof file self-declares the rows it is the proof FOR with header
// `// authz-row: <id>` lines, and `checkLedger` asserts the pairing both ways.
// The keyword is deliberately NOT `@proof:` — that channel carries ADR-0054
// LIVENESS ids, a different vocabulary from matrix row ids (the same file is
// `@proof: cbp-controlled-by-parent` and `authz-row: controlled-by-parent`), and
// collapsing them would make one gate's rename silently re-point the other's.
const ATTRIBUTION_MARKER = 'authz-row';
// Scanned so a claim can never rot unnoticed: a claim living in a dogfood file
// NO row cites is invisible to the citation walk by construction, and is exactly
// what a renamed row or a re-pointed proof leaves behind.
const scanProofCandidates = (): string[] =>
readdirSync(HERE).filter((f) => f.endsWith('.dogfood.test.ts'));
const ATTRIBUTION = { marker: ATTRIBUTION_MARKER, scan: scanProofCandidates } as const;

// ── #2567 ratchet — static enumeration of anonymous-deny HTTP entry points ──
//
// A CURATED per-file probe table (not a blind repo grep): scoped to the four
Expand DownExpand Up@@ -208,6 +223,8 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => {
// classified by exactly one row's `covers`, and no `covers` key may be
// stale (no longer in source).
discover: () => discoverAnonymousDenySurfaces(),
// #7976 — and the cited proofs must NAME the rows they prove.
attribution: ATTRIBUTION,
});
expect(problems, problems.join('\n')).toEqual([]);
});
Expand All@@ -218,7 +235,12 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => {
// needs no source edits. If these ever pass vacuously, the ratchet is asleep.
describe('#2567 — anonymous-deny surface ratchet bites', () => {
const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE));
const opts = (discover: () => Iterable<string>) => ({ proofRoot: HERE, highRisk: HIGH_RISK, discover });
const opts = (discover: () => Iterable<string>) => ({
proofRoot: HERE,
highRisk: HIGH_RISK,
discover,
attribution: ATTRIBUTION,
});

it('the real matrix + real discover is sound (baseline lock)', () => {
const problems = checkLedger(AUTHZ_CONFORMANCE, opts(() => discoverAnonymousDenySurfaces()));
Expand DownExpand Up@@ -304,3 +326,70 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => {
}
});
});

// ── #7976 — the row ↔ proof ATTRIBUTION bites ─────────────────────────────
// Existence was the whole `proof` contract before this: a row could cite a file
// that exercises a neighbouring primitive and stay green forever. These cases
// drive `checkLedger` with controlled inputs (deep-cloned matrix) so they are
// deterministic and need no source edits — the same shape as the #2567 block
// above. If they ever pass vacuously, the attribution gate is asleep and the
// ledger is back to vouching for citations nobody checked.
describe('#7976 — row ↔ proof attribution is mutual', () => {
const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE));
const opts = () => ({ proofRoot: HERE, highRisk: HIGH_RISK, attribution: ATTRIBUTION });

it('every row that cites a proof is CLAIMED by it (baseline lock)', () => {
// Baseline sanity: the walk has real work to do — this must never become a
// vacuous pass because the rows stopped carrying proofs.
const cited = AUTHZ_CONFORMANCE.filter((r) => r.proof);
expect(cited.length, 'the matrix must still cite proofs').toBeGreaterThanOrEqual(20);
expect(checkLedger(AUTHZ_CONFORMANCE, opts())).toEqual([]);
});

it('a row re-pointed at a proof that does NOT claim it fails, NAMING the row', () => {
// The exact defect #7976 filed: `flow-runas.dogfood.test.ts` exists, so the
// pre-#7976 existence check was perfectly happy with this citation.
const m = clone();
m.find((r) => r.id === 'rls-read')!.proof = 'flow-runas.dogfood.test.ts';
const problems = checkLedger(m, opts());
expect(problems.some((p) => p.startsWith('rls-read:') && /does not claim this row/.test(p))).toBe(true);
});

it('a shared proof file must claim BOTH rows — dropping one is not covered by the other', () => {
// `rls-fixture.dogfood.test.ts` proves `rls-read` AND `rls-by-id-write`
// (PR #7975). Borrowing the sibling's credibility is the thing that must fail.
const m = clone();
m.find((r) => r.id === 'rls-by-id-write')!.proof = 'controlled-by-parent.dogfood.test.ts';
const problems = checkLedger(m, opts());
expect(problems.some((p) => p.startsWith('rls-by-id-write:') && /does not claim this row/.test(p))).toBe(true);
});

it('a claim naming a row the ledger does not have is an ORPHAN', () => {
const m = clone().filter((r) => r.id !== 'flow-run-as');
const problems = checkLedger(m, opts());
expect(
problems.some((p) => p.includes('flow-runas.dogfood.test.ts') && /orphaned claim/.test(p)),
).toBe(true);
});

it('a claim the row does not reciprocate fails (attribution is not one-way)', () => {
// The row still exists and its proof still exists — only the pairing broke.
const m = clone();
m.find((r) => r.id === 'flow-run-as')!.proof = undefined;
const problems = checkLedger(m, opts());
expect(problems.some((p) => /attribution is not mutual/.test(p) && p.includes('flow-run-as'))).toBe(true);
});

it('scanning is what catches a claim no row cites at all', () => {
// Without `scan`, a file nothing cites is never read — so a renamed row
// leaves its stale claim behind, silently. With it, the same edit is loud.
const m = clone();
m.find((r) => r.id === 'owd-private')!.proof = undefined;
const unscanned = checkLedger(m, { proofRoot: HERE, attribution: { marker: ATTRIBUTION_MARKER } });
expect(unscanned.some((p) => p.includes('showcase-private-owd.dogfood.test.ts'))).toBe(false);
const scanned = checkLedger(m, opts());
expect(
scanned.some((p) => p.includes('showcase-private-owd.dogfood.test.ts') && /not mutual/.test(p)),
).toBe(true);
});
});
49 changes: 49 additions & 0 deletions packages/qa/dogfood/test/conformance-helper.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,3 +52,52 @@ describe('checkLedger (ADR-0060)', () => {
expect(checkLedger([ok({ covers: ['x', 'y'] })], { proofRoot: HERE, discover: () => ['x', 'y'] })).toEqual([]);
});
});

// #7976 — `attribution` binds a row to its proof BY NAME. Without it, existence
// is the whole contract, which is what let a row cite a file exercising a
// neighbouring primitive. These pin the helper's half in isolation; the authz
// ledger drives it end-to-end.
describe('checkLedger attribution (#7976)', () => {
// This very file carries the fixture claims below, in the form the checker
// reads them (comment-anchored, so a mention in prose or a string is not a
// claim). `checkLedger` is being pointed at the test file that declares them.
// helper-fixture-row: a
const SELF = 'conformance-helper.test.ts';
const attribution = { marker: 'helper-fixture-row' } as const;

it('is OPT-IN — existence alone still passes without it', () => {
expect(checkLedger([ok({ id: 'unclaimed', proof: SELF })], { proofRoot: HERE })).toEqual([]);
});

it('accepts a row its proof claims', () => {
expect(checkLedger([ok({ id: 'a', proof: SELF })], { proofRoot: HERE, attribution })).toEqual([]);
});

it('flags a row its proof does NOT claim, naming the row', () => {
const problems = checkLedger([ok({ id: 'unclaimed', proof: SELF })], { proofRoot: HERE, attribution });
expect(problems.some((x) => x.startsWith('unclaimed:') && x.includes('does not claim this row'))).toBe(true);
});

it('flags an orphaned claim — a claimed id that is not a ledger row', () => {
const problems = checkLedger([ok({ id: 'b', proof: SELF })], { proofRoot: HERE, attribution });
expect(problems.some((x) => x.includes('orphaned claim') && x.includes('a'))).toBe(true);
});

it('flags a one-way claim — the row exists but cites something else', () => {
const rows = [ok({ id: 'a', proof: 'authz-conformance.test.ts' }), ok({ id: 'c', proof: SELF })];
const problems = checkLedger(rows, { proofRoot: HERE, attribution });
expect(problems.some((x) => x.includes('attribution is not mutual'))).toBe(true);
});

it('`scan` reaches claims in files NO row cites', () => {
const rows = [ok({ id: 'a' })]; // 'a' is claimed by SELF, but cites nothing
expect(checkLedger(rows, { proofRoot: HERE, attribution })).toEqual([]);
const scanned = checkLedger(rows, { proofRoot: HERE, attribution: { ...attribution, scan: () => [SELF] } });
expect(scanned.some((x) => x.includes('attribution is not mutual'))).toBe(true);
});

it('a proof missing on disk is reported once, not twice', () => {
const problems = checkLedger([ok({ proof: 'does/not/exist.ts' })], { proofRoot: HERE, attribution });
expect(problems.filter((x) => x.includes('does/not/exist.ts'))).toHaveLength(1);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,14 @@
// Master-detail "controlled by parent" RLS proof (ADR-0055 P2), end-to-end
// through the real HTTP + security stack.
//
// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof
// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). The
// claim rests on this file's OWN evidence, as re-decided in PR #7975:
// `fixtures/cbp-fixture.ts` grants the member full CRUD on BOTH master and
// detail, so the derived READ and by-id WRITE denials asserted below are the
// derived record gate answering, never the object gate.
// authz-row: controlled-by-parent
//
// @proof: cbp-controlled-by-parent
// ADR-0055 runtime proof for derived master-detail access. Referenced by the
// liveness ledger entry `object.sharingModel` (packages/spec/liveness/object.json);
Expand Down
4 changes: 4 additions & 0 deletions packages/qa/dogfood/test/flow-runas.dogfood.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@
// FLOW runAs identity-enforcement proof (#1888), exercised end-to-end through the
// real HTTP + automation + security stack.
//
// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof
// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976).
// authz-row: flow-run-as
//
// @proof: flow-runas-identity
// Security-layer instance of the "configured in the UI, silently does nothing at
// runtime" anti-pattern (sibling of the assignment/decision node fixes). A flow's
Expand Down
Loading
Loading