diff --git a/.changeset/authz-proof-attribution.md b/.changeset/authz-proof-attribution.md new file mode 100644 index 0000000000..8992e84b71 --- /dev/null +++ b/.changeset/authz-proof-attribution.md @@ -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. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 008c920309..45edf13789 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -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: ` 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. diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 04e547edb9..c886d48dbe 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -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: ` 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'; @@ -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', diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index af3df295b8..3a8339c208 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -15,7 +15,7 @@ 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'; @@ -23,6 +23,21 @@ 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: ` 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 @@ -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([]); }); @@ -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) => ({ proofRoot: HERE, highRisk: HIGH_RISK, discover }); + const opts = (discover: () => Iterable) => ({ + 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())); @@ -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); + }); +}); diff --git a/packages/qa/dogfood/test/conformance-helper.test.ts b/packages/qa/dogfood/test/conformance-helper.test.ts index 597525e165..5d01838bbe 100644 --- a/packages/qa/dogfood/test/conformance-helper.test.ts +++ b/packages/qa/dogfood/test/conformance-helper.test.ts @@ -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); + }); +}); diff --git a/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts b/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts index 479bb06a50..3085d1e339 100644 --- a/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts +++ b/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts @@ -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); diff --git a/packages/qa/dogfood/test/flow-runas.dogfood.test.ts b/packages/qa/dogfood/test/flow-runas.dogfood.test.ts index 3936d64a66..9ad6c477d0 100644 --- a/packages/qa/dogfood/test/flow-runas.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-runas.dogfood.test.ts @@ -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 diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 3d58dc4a63..735ce77d17 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -19,6 +19,13 @@ // to rows the caller may edit. // // @proof: owner-anchor-and-bulk-writes +// +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). Two +// rows, two distinct bodies of evidence in this one file: the #3004 forge / +// transfer / disown cases, and the #2982 bulk update + bulk delete cases. +// authz-row: ownership-anchor-guard +// authz-row: bulk-write-owner-scoping import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts b/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts index 9a773de438..a1df25c765 100644 --- a/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts +++ b/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts @@ -2,6 +2,15 @@ // // The HARD, revert-provable #1994 gate. // +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). BOTH +// are claimed on their own evidence, as re-decided in PR #7975: the read side by +// the member who cannot GET the admin note, the by-id write by the select-only +// block below (its member set grants FULL CRUD on `rls_note`, so a refusal is +// the record gate) asserting the PATCH is refused with the row unchanged. +// authz-row: rls-read +// authz-row: rls-by-id-write +// // @proof: rls-by-id-write // ADR-0054 runtime proof for the RLS / sharing high-risk class. Referenced by the // liveness ledger entry `permission.rowLevelSecurity.using` diff --git a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts index 5cd708d816..e9d66364de 100644 --- a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts +++ b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts @@ -36,6 +36,17 @@ // // Empirically (CRM): single-tenant → every object `member-visible`; multi-tenant // → `rls-consistent` with zero holes. This test asserts that faithful state. +// +// 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 is CONDITIONAL by construction: the suite is +// `describe.skipIf(!organizationsAvailable)`, so in the open workspace it does +// not run at all and only enterprise/cloud CI (which ships +// `@objectstack/organizations`) actually exercises the row. The marker records +// what this file proves WHERE IT RUNS — it is not an assertion that open-core CI +// proved it. `OS_TEST_MULTI_ORG_ENABLED=1` turns an unexpected skip into a +// failure, which is the mechanism that keeps the skip honest (#4700). +// authz-row: multi-tenant import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import crmStack from '@objectstack/example-crm'; diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index abd5942f76..16fe5c24e1 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -28,6 +28,16 @@ // half, so the proof file once again covers everything the matrix row claims // it covers. // +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). One +// per SURFACE, each with its own anonymous-401 cases below. It deliberately does +// NOT claim `anonymous-deny`: the REST `/data` cases here are the cross-surface +// contrast, and that row's cited proof is showcase-anonymous-deny.dogfood.test.ts. +// authz-row: anonymous-deny-meta +// authz-row: anonymous-deny-actions +// authz-row: anonymous-deny-automation +// authz-row: anonymous-deny-packages +// // The value this boot adds OVER #5569's own runtime integration test // (`dispatcher-plugin.anonymous-gate.integration.test.ts`) is precisely the // comparison that test documented it could NOT make: it boots a LiteKernel that diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts index 346eae1981..ffe0b1a012 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts @@ -8,6 +8,18 @@ // control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). // Public forms survive the same default via the declaration-derived // publicFormGrant — see showcase-public-form.dogfood.test.ts. +// +// 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: anonymous-deny +// +// ⚠️ It claims `anonymous-deny` and NOTHING ELSE. The `requireAuth-removed` row +// used to cite this same file (#7976): what that row asserts is that the +// deployment-wide OPT-OUT is RETIRED — `api.requireAuth` tombstoned in spec, a +// stack that mounts no auth failing AT BOOT — and this file exercises none of +// it. It drives the platform default and observes 401, which is exactly what +// `anonymous-deny` already claims. Under mutual attribution that citation could +// not be made honestly, so it was dropped rather than rubber-stamped here. import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts index 8d0ffcb8c5..98137decf6 100644 --- a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts @@ -9,6 +9,14 @@ // rows; a non-owner in the unit subtree can then read a private record. // // @proof: showcase-bu-hierarchy-sharing +// +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). Two +// rows on two assertions: the criteria rule MATERIALISING into sys_record_share +// (`sharing-rules`, here via the `business_unit` recipient), and the subordinate +// unit's member reading through the tree (`hierarchy-widening`). +// authz-row: sharing-rules +// authz-row: hierarchy-widening import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index 66840dcc9b..350778d655 100644 --- a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -6,6 +6,10 @@ // count = 0. This proves the opposite, plus the spec→runtime translation. // // @proof: showcase-declarative-rbac-seeding +// +// 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: declarative-rbac-seeding import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts index b0da95f108..fd19705a77 100644 --- a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -34,6 +34,10 @@ // The pair below is therefore red in both directions: the first case goes red if // the declared default is not in force, the second if it DISPLACES the built-in // one instead of composing with it. +// +// 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: default-profile import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts index f88ddaff13..f7bef824e0 100644 --- a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts @@ -8,6 +8,10 @@ // caller's RLS, so a member sees only their own rows, and an anonymous caller is // denied before any tool executes. // +// 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: mcp-http-identity +// // The target object is `showcase_private_note` (OWD `private`, owner-only) — the // same object the declarative-OWD proof uses. Owner isolation is enforced by the // engine, so if the MCP tool ran unscoped (the stdio posture, mcp-stdio-authority) diff --git a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 6d8ca2d993..7d27aa2ce7 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -7,6 +7,10 @@ // package's sets, and uninstall/upgrade have a well-defined owner axis. // Proven on the real showcase stack, which declares `showcase_contributor` // and `showcase_member_default` in `src/security/`. +// +// 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: declarative-permission-seeding import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts b/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts index 9f344abbc9..a11693176d 100644 --- a/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts @@ -7,6 +7,10 @@ // every read/write to the owner purely from the OWD baseline + the auto-stamped // `owner_id`. This is the canonical "declare one word, get owner isolation" // capability — proven end-to-end through the real HTTP 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: owd-private import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts index 91a3c8b23b..49a1f176ff 100644 --- a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts @@ -9,6 +9,12 @@ // anonymous submit proves the route works under SECURE-BY-DEFAULT auth, with // NO `guest_portal` profile, authorized solely by the declaration-derived // `publicFormGrant` (create + read-back on `showcase_inquiry` ONLY). +// +// 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 +// row's own evidence is the #3022 case: a forged owner_id / organization_id on +// the anonymous submit never lands on the row. +// authz-row: public-form-managed-anchors import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts index 5ce478f02a..f9d3b8db48 100644 --- a/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts @@ -6,6 +6,10 @@ // from the OWD baseline + auto-stamped `owner_id`, no RLS authored. This is the // sibling of the `private` proof: same owner-write protection, but rows are // VISIBLE across owners (the read-visibility axis of OWD). +// +// 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: owd-public-read import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts index fe8382894f..13f16ba41b 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -12,6 +12,12 @@ // the contract end-to-end; production ships the enterprise resolver. // // @proof: showcase-scope-depth +// +// 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). It +// drives `unit`, `unit_and_below` and `own_and_reports` plus the open-edition +// fail-closed fallback; `own` is proven by showcase-private-owd. +// authz-row: scope-depth import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts index 9e25468959..77645d39cf 100644 --- a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts @@ -2,6 +2,12 @@ // // @proof: readonly-static-write // +// 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). Note +// the two vocabularies coincide here by accident, not by rule: the `@proof:` +// id above is an ADR-0054 liveness id, `authz-row:` below is a matrix row id. +// authz-row: readonly-static-write +// // #2948 / #3003 (UPDATE) + #3043 (INSERT) — static `readonly: true` is // SERVER-enforced on BOTH write paths, not a UI-only affordance. The #3003 // field report: an approval-flow object declared `approval_status` / diff --git a/packages/verify/src/conformance.ts b/packages/verify/src/conformance.ts index 7ce47ea660..b9010a3287 100644 --- a/packages/verify/src/conformance.ts +++ b/packages/verify/src/conformance.ts @@ -14,9 +14,18 @@ * `discover` enables the **ratchet**: re-derive the real surface from source and * fail when a declaration is unclassified (the #1887 / declared-but-unenforced * class) or a `covers` entry is stale. + * + * The optional `attribution` closes the gap #7976 named: a `proof` used to be + * checked for EXISTENCE only, so a row could cite a file that exercises a + * neighbouring primitive and stay green forever (`rls-read` and + * `rls-by-id-write` cited the same file, and nothing could tell whether it + * exercised one, the other, or both). "Does this test actually prove this row" + * is not mechanically decidable and is deliberately NOT attempted; the + * checkable question it is converted into is **mutual naming** — the proof file + * must claim the rows it is cited for, and every claim must be reciprocated. */ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; export type ConformanceState = 'enforced' | 'experimental' | 'removed'; @@ -32,7 +41,11 @@ export interface ConformanceRow { state: ConformanceState; /** Runtime enforcement site — REQUIRED when `state === 'enforced'`. */ enforcement?: string; - /** Proof path (resolved against {@link CheckLedgerOptions.proofRoot}); file must exist. */ + /** + * Proof path (resolved against {@link CheckLedgerOptions.proofRoot}); the file + * must exist — and, when {@link CheckLedgerOptions.attribution} is on, must + * also CLAIM this row's id (see {@link ProofAttributionOptions}). + */ proof?: string; /** Ratchet keys this row accounts for (matched against `discover()`). */ covers?: string[]; @@ -51,6 +64,60 @@ export interface CheckLedgerOptions { highRisk?: string[]; /** When true, EVERY enforced row must carry a proof (default: only high-risk). */ proofRequiredForEnforced?: boolean; + /** Bind row ↔ proof by NAME, not just by path (#7976). */ + attribution?: ProofAttributionOptions; +} + +/** + * Mutual row ↔ proof attribution (#7976). + * + * A cited proof file self-declares the rows it is a proof FOR, as header + * comment lines: `// : `, one per row. `checkLedger` then + * asserts the pairing in BOTH directions: + * + * 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 what makes a stale claim (a renamed row, a proof that was + * re-pointed elsewhere) fail rather than rot, which is why {@link scan} exists: + * a file no row cites is invisible to direction 1 by construction. + * + * A comment marker — rather than an exported manifest — is deliberate: proof + * files are test modules whose import registers (and can boot) real stacks, so + * the claim must be readable WITHOUT executing them. It is the same + * `readFileSync` the existence check already implies, and it mirrors the + * `@proof:` header idiom dogfood proofs already carry for the ADR-0054 liveness + * registry. The marker keyword is per-ledger precisely so the two vocabularies + * (liveness proof ids vs ledger row ids) cannot be confused for one another. + */ +export interface ProofAttributionOptions { + /** Claim keyword, e.g. `'authz-row'` → a proof file line `// authz-row: rls-read`. */ + marker: string; + /** + * Extra proof-root-relative files to scan for claims, beyond the ones rows + * cite. Any claim found in a file NO row cites is reported (direction 2). + */ + scan?: () => Iterable; +} + +/** Escape a literal for embedding in a RegExp source. */ +function escapeRe(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Extract a proof file's row claims. Matches the marker at the start of a + * comment line (`//` or a block-comment `*` continuation) so a mention inside + * prose or a string literal is not mistaken for a claim. + */ +function readClaims(absPath: string, marker: string): string[] { + const re = new RegExp(`^[ \\t]*(?://+|\\*)[ \\t]*${escapeRe(marker)}:[ \\t]*(\\S+)`, 'gm'); + const src = readFileSync(absPath, 'utf8'); + const claims: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(src)) !== null) claims.push(m[1]); + return claims; } const VALID_STATES: ReadonlySet = new Set(['enforced', 'experimental', 'removed']); @@ -85,6 +152,43 @@ export function checkLedger(rows: readonly ConformanceRow[], opts: CheckLedgerOp else if (!r.proof) problems.push(`high-risk ${id} must carry a proof`); } + // Mutual row ↔ proof attribution (#7976) — a proof must NAME what it proves. + if (opts.attribution) { + const { marker, scan } = opts.attribution; + + // Which rows cite each proof file (a shared file legitimately proves several). + const citedBy = new Map(); + for (const r of rows) { + if (r.proof) citedBy.set(r.proof, [...(citedBy.get(r.proof) ?? []), r.id]); + } + + for (const file of new Set([...citedBy.keys(), ...(scan?.() ?? [])])) { + const abs = join(opts.proofRoot, file); + if (!existsSync(abs)) continue; // already reported as missing on disk + const claims = readClaims(abs, marker); + const citers = citedBy.get(file) ?? []; + + // Direction 1 — the cited file must claim every row citing it. + for (const id of citers) { + if (!claims.includes(id)) { + problems.push(`${id}: proof does not claim this row — add \`${marker}: ${id}\` to ${file}, or stop citing it`); + } + } + + // Direction 2 — every claim is reciprocated by the ledger. + for (const id of claims) { + if (!seenIds.has(id)) { + problems.push(`${file}: claims \`${marker}: ${id}\`, but no such row is in the ledger (orphaned claim)`); + } else if (!citers.includes(id)) { + const cited = rows.find((x) => x.id === id)?.proof; + problems.push( + `${file}: claims \`${marker}: ${id}\`, but that row cites ${cited ? `\`${cited}\`` : 'no proof'} — attribution is not mutual`, + ); + } + } + } + } + // `covers`: each surface classified by exactly one row. const covered = new Map(); for (const r of rows) { diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index f742f4ad77..26f727a7fd 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -36,7 +36,12 @@ export type { // ADR-0060 — reusable conformance-ledger helper (static complement to the // runtime harness): classify every declarable property, fail closed on drift. export { checkLedger } from './conformance.js'; -export type { ConformanceRow, ConformanceState, CheckLedgerOptions } from './conformance.js'; +export type { + ConformanceRow, + ConformanceState, + CheckLedgerOptions, + ProofAttributionOptions, +} from './conformance.js'; // Driver read-coercion conformance: a stored value must read back as its // declared type on every driver (the case_escalation `1 != true` invariant).