diff --git a/.changeset/15953-verify-parent-write-reach-family.md b/.changeset/15953-verify-parent-write-reach-family.md new file mode 100644 index 00000000000..31b67a49e79 --- /dev/null +++ b/.changeset/15953-verify-parent-write-reach-family.md @@ -0,0 +1,43 @@ +--- +'@objectstack/verify': minor +'@objectstack/cli': minor +--- + +feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) + +Clause-②: yes + +A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. + +The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: + +- the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; +- the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. + +The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. + +Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and the run fails. + +**`@objectstack/verify` — new exports.** + +- `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. +- `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. +- `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. +- Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. + +```ts +import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; + +const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); +const report = await runParentWriteReachProofs(stack, myApp); +console.log(formatParentWriteReachReport(report)); // report.personas names who wrote +expect(report.summary.hardFailures).toBe(0); +await stack.stop(); +``` + +**`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. + +**The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: + +- **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. +- **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 3d97153b4cc..f83c26a079d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1925,6 +1925,7 @@ os verify --rls --multi-tenant --json # Org-scoped boot, structured rep os verify --reference-shapes # Also prove reference fields refuse non-id values os verify --delete-behaviors # Also prove each declared deleteBehavior is what a delete does os verify --unique-scopes --multi-tenant # Also prove each declared unique collides where its scope says +os verify --parent-write-reach # Also prove each controlled_by_parent detail write follows its master ``` 1. **Author-time rules.** The rule registry `os validate` runs, over the stack @@ -1972,9 +1973,10 @@ os verify --unique-scopes --multi-tenant # Also prove each declared unique and a second one is offered it, by insert and by update, through the REST door and the system door. Within one organization the second holder must be refused with the platform's unique refusal, attributed to the field (it - names the field, or it names none and the same record with only that value - changed is admitted). With `--multi-tenant` the run also provisions a second - organization (a sign-up that creates its own) and offers it the value: a + names the field, or it names none, the same record with only that value + changed is admitted, and no unique `indexes[]` entry covers the field). With + `--multi-tenant` the run also provisions a second organization (`signUp`, + then `POST /auth/organization/create`) and offers it the value: a `true` or `'organization'` field must admit it, and a `'global'` field, or any field of an object with no organization column, must refuse it. Without `--multi-tenant` there is one organization, so that leg is reported **not @@ -1985,6 +1987,34 @@ os verify --unique-scopes --multi-tenant # Also prove each declared unique only. A second holder met wrongly fails the run; a refusal that is not the field's own constraint (an app hook's dedupe, a rule, a permission) is reported inconclusive, never proven. + With `--parent-write-reach`, another fresh boot proves ADR-0055's write rule + for every object declared `controlled_by_parent` (exactly one + `master_detail` field names its master): editing, deleting or creating a + detail is admitted exactly when the caller holds that verb on the detail's + object **and** can edit its master. The first half is read from the + server's effective permissions for the caller (`/auth/me/permissions`); the + second is measured, by an empty update of the master as the caller, which + runs every write gate the master has — for a master that is itself + controlled by its parent, the gate above it too. The writes go through the + engine as the signed-in caller (the insert, update and delete by id the + REST data route makes, with the caller's context), where a refusal keeps + the object and the operation it names — the REST envelope names the + route's object whatever was refused; the system door bypasses + authorization and is not asked. They are made by three verifier + personas whose permission sets the run registers from the app's own objects + (the master's root by the all-records grant `modifyAllRecords` with the + detail's every verb; the master readable but not editable; the all-records + grant with no detail delete) and by one persona per declared position, each + under a master chain another principal owns and one it owns. A write whose + answer disagrees fails the run, and so does a persona that could not be + provisioned; a refusal that is not the platform's `403 PERMISSION_DENIED` + naming the detail and the operation (a rule, a hook's own code, a refusal + on another object) is reported inconclusive, never proven. An object + with two `master_detail` fields, none, or a chain at the write gate's depth + bound is reported `excluded`. Today's engine refuses every write to a detail + two levels below its root master (a line under a quote under an account), + even where the caller edits the middle master, so an app that declares such + a chain fails the run. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or `group`) asks for the same boot. @@ -2014,8 +2044,9 @@ document carries depends on where the run ended: - **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`, `crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`), `deleteBehaviors` (with `--delete-behaviors`), `uniqueScopes` (with - `--unique-scopes`; its `boot` names the legs the run could prove) and - `hardFailures`, the count + `--unique-scopes`; its `boot` names the legs the run could prove), + `parentWriteReach` (with `--parent-write-reach`; its `personas` name who + wrote and who could not be provisioned) and `hardFailures`, the count the exit status is read from. - **Could not run:** `{ "error": "" }`, plus `code` and `httpStatus` when the failure carries them. diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 95ab269a525..f3f99713a7e 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -21,11 +21,15 @@ import { formatDeleteBehaviorReport, runUniqueScopeProofs, formatUniqueScopeReport, + runParentWriteReachProofs, + formatParentWriteReachReport, + parentWriteReachSecurity, type VerifyReport, type RlsReport, type ReferenceShapeReport, type DeleteBehaviorReport, type UniqueScopeReport, + type ParentWriteReachReport, type RlsProbeDescriptor, type RlsPositionPersonaInput, } from '@objectstack/verify'; @@ -99,12 +103,17 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b * the REST door and the system door, and — on an org-scoped boot * (--multi-tenant) — admits or refuses one in another organization * as its declared scope says + * - parent-derived write reach (--parent-write-reach, ADR-0055 §3): + * for every `controlled_by_parent` object, a persona's edit, delete + * or create of a detail, written through the engine as the persona, + * is admitted exactly when it holds that verb on the detail's object + * and can edit the master * * Exits non-zero on either stage's failures so it drops straight into CI. */ export default class Verify extends Command { static override description = - 'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity, plus the cross-owner RLS invariant, the reference-value shape proofs, the declared delete-behaviour proofs and the declared unique-scope proofs on request)'; + 'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity, plus the cross-owner RLS invariant, the reference-value shape proofs, the declared delete-behaviour proofs, the declared unique-scope proofs and the parent-derived write reach proofs on request)'; static override examples = [ '<%= config.bin %> verify', @@ -113,6 +122,7 @@ export default class Verify extends Command { '<%= config.bin %> verify --reference-shapes', '<%= config.bin %> verify --delete-behaviors', '<%= config.bin %> verify --unique-scopes --multi-tenant', + '<%= config.bin %> verify --parent-write-reach', ]; static override flags = { @@ -136,6 +146,10 @@ export default class Verify extends Command { description: "Also prove every field declared unique collides as its scope says (true / 'organization': within one organization only; 'global': across the installation), through the REST door and the system door; the cross-organization half needs --multi-tenant and is reported not run without it (ADR-0120 unique scopes)", default: false, }), + 'parent-write-reach': Flags.boolean({ + description: "Also prove, for every controlled_by_parent object, that a persona's edit, delete or create of a detail is admitted exactly when it holds that verb on the detail's object and can edit the master, written through the engine as verifier personas and one persona per declared position (ADR-0055 master-detail write reach)", + default: false, + }), 'multi-tenant': Flags.boolean({ description: 'Boot org-scoped (register the @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors a walled $OS_TENANCY_POSTURE, and the legacy $OS_MULTI_ORG_ENABLED it falls back to)', default: false, @@ -216,6 +230,7 @@ export default class Verify extends Command { 'reference-shapes': boolean; 'delete-behaviors': boolean; 'unique-scopes': boolean; + 'parent-write-reach': boolean; 'multi-tenant': boolean; json: boolean; }): Promise { @@ -359,6 +374,19 @@ export default class Verify extends Command { } } + // The parent-derived write reach proofs run on a fresh stack of their own, + // booted with the verifier personas' permission sets registered: they + // sign up personas, and they edit, delete and create details on purpose. + let parentWriteReach: ParentWriteReachReport | undefined; + if (flags['parent-write-reach']) { + const parentStack = await bootStack(config, { multiTenant, hostRoot, security: parentWriteReachSecurity(config) }); + try { + parentWriteReach = await runParentWriteReachProofs(parentStack, config); + } finally { + await parentStack.stop(); + } + } + // Failure contract: a "real" runtime break the app's author must see. // A degraded RLS probe counts: the run reported verdicts it could not have // established, which is worse than no verifier at all. @@ -387,19 +415,23 @@ export default class Verify extends Command { // A second holder a declared unique scope met wrongly, or a door that // faulted on one. A leg this boot cannot run is reported on the report's // `boot`, not counted. - (uniqueScopes?.summary.hardFailures ?? 0); + (uniqueScopes?.summary.hardFailures ?? 0) + + // A detail write whose answer disagreed with the conjunction, a door + // that faulted on one, or a persona that could not be provisioned. + (parentWriteReach?.summary.hardFailures ?? 0); if (flags.json) { // `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the // report has to leave through the real stream — and awaiting the write // drains a report larger than one pipe buffer before `this.exit` below. - await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, hardFailures }); + await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, parentWriteReach, hardFailures }); } else { this.log(formatReport(crud)); if (rls) this.log(formatRlsReport(rls)); if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes)); if (deleteBehaviors) this.log(formatDeleteBehaviorReport(deleteBehaviors)); if (uniqueScopes) this.log(formatUniqueScopeReport(uniqueScopes)); + if (parentWriteReach) this.log(formatParentWriteReachReport(parentWriteReach)); this.log(''); this.log( hardFailures > 0 diff --git a/packages/cli/test/verify-parent-write-reach.test.ts b/packages/cli/test/verify-parent-write-reach.test.ts new file mode 100644 index 00000000000..e7c13170614 --- /dev/null +++ b/packages/cli/test/verify-parent-write-reach.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN — `os verify --parent-write-reach` runs the parent-derived write reach + * family and counts what it finds in the exit status. + * + * ## What is pinned + * + * - without the flag the run is what it was: no `parentWriteReach` key in + * the `--json` document, and the exit status reads CRUD only; + * - with the flag, the stack is booted with the verifier personas' sets + * registered, so all three verifier personas run beside the one declared + * position; the `controlled_by_parent` contract is `proven`, its manager + * edits another principal's contract through the account and is refused + * the delete it lacks, `hardFailures` stays 0 and the run exits 0; + * - with the flag, on the same metadata plus a hook that refuses every edit + * of the contract with the platform's own envelope (`403 + * PERMISSION_DENIED`, `details` naming the update of the contract), the + * detail's answer disagrees with its master's: the contract is reported + * `contradicted`, `hardFailures` counts it and the run exits 1. + * + * Spawned rather than run in-process: the exit status is the contract a CI + * gate reads, and only a real process has one. + */ + +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { defineStackSource, defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; + +const MANIFEST = { + id: 'com.example.verify-parent-write-reach', + namespace: 'vpw', + version: '1.0.0', + name: 'Verify Parent Write Reach', + type: 'app', + engines: { protocol: '^18' }, +}; + +const OBJECTS = [ + { + name: 'vpw_account', + label: 'Account', + pluralLabel: 'Accounts', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name', required: true } }, + }, + { + name: 'vpw_contract', + label: 'Contract', + pluralLabel: 'Contracts', + sharingModel: 'controlled_by_parent', + fields: { + account: { type: 'master_detail', label: 'Account', reference: 'vpw_account', required: true }, + title: { type: 'text', label: 'Title' }, + }, + }, +]; + +const PERMISSIONS = [ + { + name: 'vpw_manager', + label: 'Manager', + objects: { + vpw_account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + vpw_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + }, + }, +]; + +const POSITIONS = [{ name: 'vpw_manager', label: 'Manager' }]; + +/** The same app, plus a hook that refuses every edit of a contract with the platform's own code. */ +const CONTROL_SOURCE = `{ + manifest: ${JSON.stringify({ ...MANIFEST, id: 'com.example.verify-parent-write-reach-control' })}, + objects: ${JSON.stringify(OBJECTS)}, + permissions: ${JSON.stringify(PERMISSIONS)}, + positions: ${JSON.stringify(POSITIONS)}, + hooks: [ + { + name: 'vpw_contract_refuses_every_edit', + object: 'vpw_contract', + events: ['beforeUpdate'], + handler: async () => { + throw Object.assign(new Error('refused by the app'), { + code: 'PERMISSION_DENIED', + status: 403, + details: { operation: 'update', object: 'vpw_contract' }, + }); + }, + }, + ], +}`; + +interface Run { + status: number | null; + stdout: string; + stderr: string; +} + +interface Doc { + hardFailures?: number; + parentWriteReach?: { + door: string; + personas: Array<{ name: string; kind: string; status: string }>; + results: Array<{ + object: string; + status: string; + reaches: Array<{ persona: string; masters: string; masterEdit?: string; probes: Array<{ verb: string; detailOwner?: string; expected: string; outcome: string }> }>; + }>; + summary: { proven: number; contradicted: number; failed: number; hardFailures: number; personas: { run: number; notRun: number } }; + }; +} + +function runCli(dir: string, args: string[]): Run { + // Through tsx, so the child runs this checkout's `src/`. + const r = spawnSync(TSX, [CLI, ...args], { + cwd: dir, + encoding: 'utf8', + env: childEnv({ NO_COLOR: '1' }), + maxBuffer: 64 * 1024 * 1024, + }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +const docOf = (run: Run): Doc => JSON.parse(run.stdout) as Doc; + +const shape = (doc: Doc, persona: string, masters: string) => + doc.parentWriteReach!.results[0]!.reaches + .find((r) => r.persona === persona && r.masters === masters)! + .probes.map((p) => `${p.verb}${p.detailOwner ? `/${p.detailOwner}` : ''} ${p.expected} ${p.outcome}`); + +describe('os verify --parent-write-reach', () => { + let root: string; + let plain: Run; + let proven: Run; + let control: Run; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-verify-parent-write-reach-')); + const app = join(root, 'app'); + const controlApp = join(root, 'control'); + for (const [dir, source] of [ + [app, defineStackSource({ manifest: MANIFEST, objects: OBJECTS, permissions: PERMISSIONS, positions: POSITIONS })], + [controlApp, defineStackSourceFromLiteral(CONTROL_SOURCE)], + ] as const) { + mkdirSync(dir); + writeFileSync(join(dir, 'objectstack.config.mjs'), source); + linkSpec(dir); + } + plain = runCli(app, ['verify', '--json']); + proven = runCli(app, ['verify', '--parent-write-reach', '--json']); + control = runCli(controlApp, ['verify', '--parent-write-reach', '--json']); + }, 480_000); + + afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); + }); + + it('leaves the run unchanged without the flag', () => { + expect(plain.status, `${plain.stdout}\n${plain.stderr}`).toBe(0); + const doc = docOf(plain); + expect('parentWriteReach' in doc).toBe(false); + expect(doc.hardFailures).toBe(0); + }); + + it("proves the contract for every persona, the manager's reach through the account included, and exits 0", () => { + expect(proven.status, `${proven.stdout}\n${proven.stderr}`).toBe(0); + const doc = docOf(proven); + const report = doc.parentWriteReach!; + expect(report.door).toBe('user'); + expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ + 'master-all-records run', 'detail-only run', 'master-all-records-no-delete run', 'position:vpw_manager run', + ]); + expect(report.results).toEqual([expect.objectContaining({ object: 'vpw_contract', status: 'proven' })]); + // Another principal's account, edited by the all-records grant: the contract follows, never its delete. + expect(shape(doc, 'position:vpw_manager', 'other')).toEqual([ + 'edit/other allowed held', 'delete/other refused held', 'edit/own allowed held', 'delete/own refused held', 'create allowed held', + ]); + expect(report.summary).toMatchObject({ proven: 1, contradicted: 0, failed: 0, hardFailures: 0, personas: { run: 4, notRun: 0 } }); + expect(doc.hardFailures).toBe(0); + }); + + it("counts a detail whose answer disagrees with its master's, and exits 1", () => { + expect(control.status, `${control.stdout}\n${control.stderr}`).toBe(1); + const doc = docOf(control); + expect(doc.parentWriteReach!.results).toEqual([expect.objectContaining({ object: 'vpw_contract', status: 'contradicted' })]); + expect(shape(doc, 'master-all-records', 'other')).toEqual([ + 'edit/other allowed contradicted', 'delete/other allowed held', 'edit/own allowed contradicted', 'delete/own allowed held', 'create allowed held', + ]); + expect(doc.parentWriteReach!.summary).toMatchObject({ contradicted: 1, hardFailures: 1 }); + expect(doc.hardFailures).toBe(1); + }); +}); diff --git a/packages/verify/README.md b/packages/verify/README.md index 53f80bc0d9d..5d8705cf861 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -1,7 +1,7 @@ # @objectstack/verify Boot any ObjectStack app **in-process** and verify it through the **real HTTP -stack** — no mocks, no ports, no sockets. Five app-agnostic proof families, all +stack** — no mocks, no ports, no sockets. Six app-agnostic proof families, all derived from your own metadata: - **Data fidelity** — author one record per object covering every field type, @@ -29,6 +29,13 @@ derived from your own metadata: org-scoped boot a second organization is provisioned too, and there a `true` / `'organization'` value must be admitted while a `'global'` one, or one on an object with no organization column, must be refused. +- **Parent-derived write reach** (opt-in) — *a write on a `controlled_by_parent` + detail is admitted exactly when the caller holds that verb on the detail's + object and can edit its master* (ADR-0055 §3): per detail object, persona and + master chain, an edit, a delete and a create through the engine as that + persona, each compared with that conjunction — the caller's effective grants + on the detail, and its edit of the master MEASURED by an empty update of the + master. And, on the same booted stack, an **in-process handle** so an app's own tests can run a hook, a validation rule, a flow, an action, a seed or a read against @@ -73,6 +80,9 @@ objectstack verify --delete-behaviors # also prove every declared unique collides where its scope says (both legs) objectstack verify --unique-scopes --multi-tenant + +# also prove every controlled_by_parent detail write follows its master +objectstack verify --parent-write-reach ``` `--reference-shapes` is opt-in, on a fresh boot of its own: the engine still @@ -96,16 +106,43 @@ probed through the system door only, which keeps an explicit value. Without cross-organization leg is reported **not run**, with the reason, on the report's `boot` — run it org-scoped to prove that `true` / `'organization'` lets two organizations hold one value (the walled boot needs a declared -membership policy, `OS_AUTH_MEMBERSHIP_POLICY`). A refusal proves the +membership policy: the `auth.membership_policy` setting or +`OS_AUTH_MEMBERSHIP_POLICY`). A refusal proves the collision only when it is the platform's unique refusal and it is this field's: it names the field, or it names none (a per-organization constraint is an expression index, which the driver reports by index name) and the same record -with only the probed value changed is admitted. +with only the probed value changed is admitted. Where a unique `indexes[]` +entry covers the field, a refusal that names none is `unattributed`: that +entry refuses the same write, naming no field either, so it may be the +entry's rather than the field's own declaration. + +`--parent-write-reach` is opt-in, on a fresh boot of its own that registers +the three verifier personas' permission sets (`parentWriteReachSecurity`). The +population is every object the spec's parse declares `controlled_by_parent` +with exactly one `master_detail` field, and the chain of masters above it. The +writes are made by three verifier personas — the root master by the +all-records grant (`modifyAllRecords`) with every verb on the detail; the root +master readable but not editable with every verb on the detail; the +all-records grant with no delete on the detail — and by one persona per +declared position (its capability is what the app binds to the position), each +under a master chain another principal owns and one it owns, and on a detail +another principal owns and one it owns. The writes go through the engine as +the persona (the insert, update and delete by id the REST data route makes, +with the caller's context): there a refusal keeps the object and operation it +names, while the REST envelope names the route's object whatever was refused. +A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the +detail and the operation; any other refusal (a rule, a hook's own code, a +refusal on another object) is `unattributed`. The system door bypasses +authorization and is not asked. Today's engine refuses every write to a detail +two levels below its root master, even where the caller edits the middle +master, so on an app that declares such a chain the family reports a +contradiction and fails the run. Exit code is **non-zero** on real failures (`create-failed`, `read-failed`, `fidelity-gaps`, `rls-hole`, with `--reference-shapes` a field with `holes` or -`failed`, and with `--delete-behaviors` or `--unique-scopes` a field -`contradicted` or `failed`) so +`failed`, with `--delete-behaviors` or `--unique-scopes` a field +`contradicted` or `failed`, and with `--parent-write-reach` an object +`contradicted` or `failed` or a persona that could not be provisioned) so it drops straight into a CI gate. Inconclusive verdicts (`needs-fixture`, `skipped`, `member-visible`) are warnings and exit 0. @@ -117,6 +154,7 @@ import { runReferenceShapeProofs, formatReferenceShapeReport, runDeleteBehaviorProofs, formatDeleteBehaviorReport, runUniqueScopeProofs, formatUniqueScopeReport, + runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity, } from '@objectstack/verify'; import myApp from './objectstack.config.js'; @@ -156,6 +194,13 @@ const uniques = await runUniqueScopeProofs(uniqueStack, await uniqueStack.signIn console.log(formatUniqueScopeReport(uniques)); // `uniques.boot` says which legs ran expect(uniques.summary.hardFailures).toBe(0); await uniqueStack.stop(); + +// Parent-derived write reach, on a fresh stack that registers the verifier personas' sets +const parentStack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); +const reach = await runParentWriteReachProofs(parentStack, myApp); +console.log(formatParentWriteReachReport(reach)); // `reach.personas` says who wrote +expect(reach.summary.hardFailures).toBe(0); +await parentStack.stop(); ``` ## The in-process handle (drive the real engine from a test) @@ -372,10 +417,34 @@ on a walled boot `across` × door × insert: | `proven` | every second holder met what the declared scope says: refused within one organization; across, admitted for `true` / `'organization'`, refused for `'global'` or with no organization column | | `contradicted` | a second holder was admitted where the scope refuses it, or refused where it admits it (the constraint is installation-wide, and its refusal tells one organization another holds the value) **(failure)** | | `failed` | a door faulted **(failure)** | -| `inconclusive` | a refusal was not this field's unique constraint (an app's own dedupe, a rule, a permission, a constraint on another field), or a fixture could not be written (the reason is reported) | +| `inconclusive` | a refusal was not this field's unique constraint (an app's own dedupe, a rule, a permission, a constraint on another field, or one that names no field where a unique `indexes[]` entry covers the field), or a fixture could not be written (the reason is reported) | | `excluded` | a derived value (`formula`, `summary`) is not stored, or the declaration does not parse | | `not-derivable` | no fresh value can be built: a closed or tiny value space (`boolean`, `select`, …), a multi-valued reference, a target the app does not declare, or an object the CRUD derivation cannot synthesize | +**Parent-derived write reach** (`runParentWriteReachProofs`), per object +declared `controlled_by_parent`. Probes per persona and master chain (another +principal's, the persona's own): edit and delete of a detail another principal +owns and of one the persona owns, and a create. Each is expected `allowed` +exactly when the persona's effective grant on the detail (`/auth/me/permissions`, +folded by `objectPermissionGrants`) holds the verb and the persona's empty +update of the master was admitted: + +| status | meaning | +| --- | --- | +| `proven` | every write met that conjunction: admitted where both halves hold, refused with `403 PERMISSION_DENIED` naming the detail and the operation where either does not | +| `contradicted` | a write met the other answer — refused where the persona holds the verb and edits the master, admitted where it does not, or refused while the record shows it landed **(failure)** | +| `failed` | a door faulted **(failure)** | +| `inconclusive` | a refusal was not the platform's authorization refusal of this write (a rule, a hook's own code, a refusal on another object), a fixture could not be written, or a master's edit could not be measured (the reason is reported) | +| `excluded` | the declaration names no one master: no `master_detail` field, two of them, a master the app does not declare, a cycle, or a chain at the write gate's depth bound | +| `not-derivable` | the detail or a master in its chain cannot be synthesized by the CRUD derivation | + +A master chain a persona cannot read is outside its reach and is counted, not +probed (a write to a record the caller cannot read is the RLS family's +subject). A persona whose `/auth/me/permissions` answer names no permission set +is reported not run: that empty answer is also the endpoint's when it could not +compute one, so it is not read as "no grant anywhere". `summary.hardFailures` is +`contradicted + failed` objects plus personas that could not be provisioned. + `member-visible` everywhere usually means the app is single-tenant; pass `--multi-tenant` (or `{ multiTenant: true }`) to register org-scoping so tenant isolation policies actually apply. @@ -421,6 +490,11 @@ run" must never read like "nothing to find". second organization meets, or the reason it is not compared; `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport` (`boot` names the legs this boot ran; `summary.hardFailures` is what a gate reads); `formatUniqueScopeReport(report)`. +- `deriveParentWriteReachCases(config)` → one case per object declared `controlled_by_parent`, with the chain of + `master_detail` hops a write on it walks, or the reason it is not compared; `parentWriteReachSecurity(config)` → the + `SecurityPlugin` to boot with (registers the verifier personas' sets); `runParentWriteReachProofs(stack, config)` → + `ParentWriteReachReport` (`personas` names who wrote and who could not be provisioned; `summary.hardFailures` is what + a gate reads); `formatParentWriteReachReport(report)`. `bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin` for owner-scoped fixtures), `multiTenant` (also what decides the posture @@ -430,6 +504,11 @@ that declares `requires: ['automation']` gets it without this), `orgContext`, `databaseFile`, `extraPlugins`, `artifact` (boot the compiled artifact as the app, so hooks and actions run their lowered `body`). +The `config` `bootStack` takes is `defineStack` output, as every example above +passes it: a raw object literal registers its fields as authored, without the +spec's parse defaults, so an `autonumber` that authors no `unique` gets no +unique scope and no index. + ## Known limitations - **Intentional write-transforms read back as fidelity gaps.** The fidelity diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index f100aaf6ef1..0230cbdecca 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -788,6 +788,12 @@ function claimConfiguration(config: unknown): ConfigurationClaim { /** * Boot an app config in-process and return a live verification stack. * + * `config` is `defineStack` output, which is what an app's configuration + * module exports and what `objectstack verify` loads: a raw object literal is + * not parsed here, so it registers its fields as authored, without the spec's + * parse defaults (an `autonumber` that authors no `unique` gets no unique + * scope and no index). + * * `NODE_ENV` is forced to `development` so the auth plugin's dev-admin * bootstrap provisions a known, loginable admin (mirrors `objectstack dev`). * diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 5ceca92d529..90ba308c903 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -3,12 +3,13 @@ // @objectstack/verify — public API. // // Boot any ObjectStack app in-process and verify it through the real HTTP -// stack. Five proof families, all app-agnostic (derived from your metadata): +// stack. Six proof families, all app-agnostic (derived from your metadata): // - data fidelity : runCrudVerification — author → write → read → assert // - authorization : runRlsProofs — "you can't write what you can't read" // - value shape : runReferenceShapeProofs — "a reference refuses a value that is not a record id" // - delete behaviour: runDeleteBehaviorProofs — "a declared deleteBehavior is what a delete does" // - unique scope : runUniqueScopeProofs — "a declared unique collides where its scope says" +// - parent write : runParentWriteReachProofs — "a detail write needs edit on its master, and its own verb" export { bootStack, bootStackOnce } from './harness.js'; export type { VerifyStack, BootOptions } from './harness.js'; @@ -125,6 +126,32 @@ export type { UniqueScopeProofOptions, } from './unique-scope.js'; +// ADR-0055 §3 — the parent-derived write reach family: for every object a +// stack declares `controlled_by_parent`, a principal's edit, delete or create +// of a detail is admitted exactly when it holds that verb on the detail's +// object AND can edit the master (measured, never re-derived), asked of the +// engine's own write as the persona, signed in: verifier and declared-position +// personas. +export { + deriveParentWriteReachCases, + runParentWriteReachProofs, + formatParentWriteReachReport, + parentWriteReachSecurity, +} from './parent-write-reach.js'; +export type { + ParentWriteReachHop, + ParentWriteReachCase, + ParentWriteReachVerb, + ParentWriteReachOwner, + ParentWriteReachAnswer, + ParentWriteReachProbeResult, + ParentWriteReachReach, + ParentWriteReachObjectStatus, + ParentWriteReachObjectResult, + ParentWriteReachPersona, + ParentWriteReachReport, +} from './parent-write-reach.js'; + // 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'; diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts new file mode 100644 index 00000000000..fbe87f73169 --- /dev/null +++ b/packages/verify/src/parent-write-reach.test.ts @@ -0,0 +1,463 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The parent-derived write reach family — what it reads from the metadata, +// what it proves on a real stack, and the controls that keep its green honest: +// +// • the NEGATIVE control: an app hook on a detail that refuses with the +// platform's own `403 PERMISSION_DENIED` where the master is editable, so +// the detail's answer disagrees with its master's — found without any +// engine change, and reported contradicted; +// • the ATTRIBUTION control: a refusal that is not the platform's +// authorization refusal (an app's own code) never reads as a held refusal; +// • a run on a stack booted without `parentWriteReachSecurity` reports the +// verifier personas not run, with the reason, and counts them. +// +// The door is the engine's write as each persona (the calls the REST data +// ingress makes, with the caller's context), so a refusal keeps its own +// `details`: attribution reads the object and the operation it names. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { defineStack } from '@objectstack/spec'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { + deriveParentWriteReachCases, + runParentWriteReachProofs, + formatParentWriteReachReport, + parentWriteReachSecurity, + type ParentWriteReachReport, +} from './parent-write-reach.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; +const RUN_TIMEOUT = 180_000; + +const manager = { + name: 'pwr_manager', + label: 'Manager', + objects: { + // Every account by the all-records grant, owning none of them. + pwr_account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + // Edit and create a contract, never delete one. + pwr_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + pwr_quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + pwr_quote_line: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + }, +}; +const rep = { + name: 'pwr_rep', + label: 'Rep', + objects: { + // Its own accounts only, and a contract it reads but never edits. + pwr_account: { allowCreate: true, allowRead: true, allowEdit: true, readScope: 'own' }, + pwr_contract: { allowRead: true }, + pwr_quote: { allowCreate: true, allowRead: true, allowEdit: true }, + pwr_quote_line: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + }, +}; + +const source = { + manifest: { id: 'com.example.parent-write-reach', namespace: 'pwr', version: '0.0.1', type: 'app', name: 'Parent Write Reach Fixture' }, + objects: [ + { name: 'pwr_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { name: 'pwr_person', label: 'Person', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { + name: 'pwr_contract', + label: 'Contract', + sharingModel: 'controlled_by_parent', + fields: { + account: { type: 'master_detail', label: 'Account', reference: 'pwr_account' }, + // A second required reference, to an object the personas hold no grant on. + signer: { type: 'lookup', label: 'Signer', reference: 'pwr_person', required: true }, + title: { type: 'text', label: 'Title' }, + }, + }, + { + name: 'pwr_quote', + label: 'Quote', + sharingModel: 'controlled_by_parent', + fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwr_account' }, title: { type: 'text', label: 'Title' } }, + }, + { + // Two levels: a line's master is a quote, whose own master is an account. + name: 'pwr_quote_line', + label: 'Quote line', + sharingModel: 'controlled_by_parent', + fields: { quote: { type: 'master_detail', label: 'Quote', reference: 'pwr_quote' }, title: { type: 'text', label: 'Title' } }, + }, + ], + permissions: [manager, rep], + positions: [ + { name: 'pwr_manager', label: 'Manager' }, + { name: 'pwr_rep', label: 'Rep' }, + ], +}; + +const VERIFIERS = ['master-all-records', 'detail-only', 'master-all-records-no-delete'] as const; + +const resultFor = (report: ParentWriteReachReport, object: string) => { + const r = report.results.find((x) => x.object === object); + if (!r) throw new Error(`no result for ${object}`); + return r; +}; + +const reachOf = (report: ParentWriteReachReport, object: string, persona: string, masters: 'other' | 'own') => { + const r = resultFor(report, object).reaches.find((x) => x.persona === persona && x.masters === masters); + if (!r) throw new Error(`no reach for ${object} ${persona} ${masters}`); + return r; +}; + +/** `verb[/detail owner] expected outcome`, per probe of one reach. */ +const shape = (report: ParentWriteReachReport, object: string, persona: string, masters: 'other' | 'own') => + reachOf(report, object, persona, masters).probes.map((p) => `${p.verb}${p.detailOwner ? `/${p.detailOwner}` : ''} ${p.expected} ${p.outcome}`); + +const ALL_ALLOWED = [ + 'edit/other allowed held', 'delete/other allowed held', 'edit/own allowed held', 'delete/own allowed held', 'create allowed held', +]; +const ALL_REFUSED = [ + 'edit/other refused held', 'delete/other refused held', 'edit/own refused held', 'delete/own refused held', 'create refused held', +]; +const NO_DELETE = [ + 'edit/other allowed held', 'delete/other refused held', 'edit/own allowed held', 'delete/own refused held', 'create allowed held', +]; + +describe('deriveParentWriteReachCases — what the family reads from the metadata', () => { + it("reads controlled_by_parent and the master through the spec's parse, walks the chain, and names what it cannot compare", () => { + // Ten controlled_by_parent objects in a row above an account: eight + // controlled_by_parent masters above `pwr_deep_0` and `pwr_deep_1` reach + // the write gate's depth bound; `pwr_deep_2` has seven. + const deep = Array.from({ length: 10 }, (_, i) => ({ + name: `pwr_deep_${i}`, + sharingModel: 'controlled_by_parent', + fields: { up: { type: 'master_detail', reference: i === 9 ? 'pwr_account' : `pwr_deep_${i + 1}` } }, + })); + const config = { + objects: [ + ...source.objects, + { name: 'pwr_public', sharingModel: 'public_read_write', fields: { account: { type: 'master_detail', reference: 'pwr_account' } } }, + { + name: 'pwr_two_masters', + sharingModel: 'controlled_by_parent', + fields: { + account: { type: 'master_detail', reference: 'pwr_account' }, + person: { type: 'master_detail', reference: 'pwr_person' }, + }, + }, + { name: 'pwr_no_master', sharingModel: 'controlled_by_parent', fields: { account: { type: 'lookup', reference: 'pwr_account', required: true } } }, + { name: 'pwr_outside', sharingModel: 'controlled_by_parent', fields: { owner: { type: 'master_detail', reference: 'crm_elsewhere' } } }, + { name: 'pwr_loop_a', sharingModel: 'controlled_by_parent', fields: { b: { type: 'master_detail', reference: 'pwr_loop_b' } } }, + { name: 'pwr_loop_b', sharingModel: 'controlled_by_parent', fields: { a: { type: 'master_detail', reference: 'pwr_loop_a' } } }, + { name: 'pwr_badge', label: 'Badge', fields: { photo: { type: 'image', required: true } } }, + { name: 'pwr_badge_line', sharingModel: 'controlled_by_parent', fields: { badge: { type: 'master_detail', reference: 'pwr_badge' } } }, + { name: 'pwr_bad', sharingModel: 'controlled_by_parent', fields: { account: { type: 'master_detail', reference: 'pwr_account', bogus: true } } }, + ...deep, + ], + }; + const byObject = new Map(deriveParentWriteReachCases(config).map((c) => [c.object, c])); + + // Every object the parse declares controlled_by_parent, and nothing else. + expect([...byObject.keys()].sort()).toEqual([ + 'pwr_bad', 'pwr_badge_line', 'pwr_contract', + ...deep.map((d) => d.name).sort(), + 'pwr_loop_a', 'pwr_loop_b', 'pwr_no_master', 'pwr_outside', 'pwr_quote', 'pwr_quote_line', 'pwr_two_masters', + ].sort()); + expect(byObject.get('pwr_contract')).toEqual({ object: 'pwr_contract', hops: [{ object: 'pwr_contract', field: 'account', master: 'pwr_account' }] }); + expect(byObject.get('pwr_quote_line')).toEqual({ + object: 'pwr_quote_line', + hops: [ + { object: 'pwr_quote_line', field: 'quote', master: 'pwr_quote' }, + { object: 'pwr_quote', field: 'account', master: 'pwr_account' }, + ], + }); + // Named instead of probed. + expect(byObject.get('pwr_two_masters')!.excluded).toMatch(/^pwr_two_masters declares 2 master_detail fields \(account, person\)/); + expect(byObject.get('pwr_no_master')!.excluded).toMatch(/^pwr_no_master declares no master_detail field/); + expect(byObject.get('pwr_outside')!.excluded).toMatch(/^the master crm_elsewhere is not an object this app declares/); + expect(byObject.get('pwr_loop_a')!.excluded).toMatch(/^the master chain re-enters pwr_loop_a \(a cycle\)/); + expect(byObject.get('pwr_deep_0')!.excluded).toMatch(/^the master chain reaches 8 controlled_by_parent masters, the write gate's depth bound/); + expect(byObject.get('pwr_deep_1')!.excluded).toMatch(/^the master chain reaches 8 controlled_by_parent masters/); + expect(byObject.get('pwr_deep_2')!.excluded).toBeUndefined(); + expect(byObject.get('pwr_deep_2')!.hops).toHaveLength(8); + expect(byObject.get('pwr_deep_2')!.hops.at(-1)).toEqual({ object: 'pwr_deep_9', field: 'up', master: 'pwr_account' }); + expect(byObject.get('pwr_badge_line')!.notDerivable).toMatch(/^the master pwr_badge cannot be synthesized: required image field "photo"/); + expect(byObject.get('pwr_bad')!.excluded).toMatch(/^the object does not parse under the spec's ObjectSchema/); + }); +}); + +describe('runParentWriteReachProofs — the fixture app, booted with parentWriteReachSecurity', () => { + let stack: VerifyStack; + let report: ParentWriteReachReport; + const app = defineStack(source as never) as unknown as typeof source; + + beforeAll(async () => { + stack = await bootStack(app as never, { security: parentWriteReachSecurity(app) }); + report = await runParentWriteReachProofs(stack, app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('runs every persona: the three verifier personas and one per declared position', () => { + expect(report.door).toBe('user'); + expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ + ...VERIFIERS.map((v) => `${v} run`), 'position:pwr_manager run', 'position:pwr_rep run', + ]); + // The server's own resolution of the sets each holds. + expect(report.personas.find((p) => p.name === 'position:pwr_manager')!.permissionSets).toContain('pwr_manager'); + expect(report.personas.find((p) => p.name === 'master-all-records')!.permissionSets).toContain('verify_parent_write_master_all'); + }); + + it('proves every single-level detail, for every persona, under a master chain another principal owns and one it owns', () => { + for (const object of ['pwr_contract', 'pwr_quote']) { + expect(resultFor(report, object).status, formatParentWriteReachReport(report)).toBe('proven'); + for (const masters of ['other', 'own'] as const) { + expect(shape(report, object, 'master-all-records', masters)).toEqual(ALL_ALLOWED); + // The detail's own verbs, and the master readable but not editable: refused by the master. + expect(shape(report, object, 'detail-only', masters)).toEqual(ALL_REFUSED); + expect(reachOf(report, object, 'detail-only', masters).masterEdit).toBe('not-editable'); + // The master's all-records grant widens which rows, never which verbs. + expect(shape(report, object, 'master-all-records-no-delete', masters)).toEqual(NO_DELETE); + } + } + }); + + it("reads hotcrm's contract shape: the manager writes another's contract through the account, never deletes it; the rep's object gate stands", () => { + // The account is another principal's; the manager edits it by the all-records grant alone. + const other = reachOf(report, 'pwr_contract', 'position:pwr_manager', 'other'); + expect(other).toMatchObject({ masterRead: 'readable', masterEdit: 'editable', grants: { edit: true, delete: false, create: true } }); + expect(shape(report, 'pwr_contract', 'position:pwr_manager', 'other')).toEqual(NO_DELETE); + // A refusal is the platform's authorization refusal for the detail. + expect(other.probes.find((p) => p.verb === 'delete')!.answer).toMatchObject({ + kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_contract', operation: 'delete', + }); + // The rep cannot read another's account, so that chain is outside its reach, and named. + expect(reachOf(report, 'pwr_contract', 'position:pwr_rep', 'other')).toMatchObject({ masterRead: 'unreadable', probes: [] }); + // On its own account the rep edits the master and is still refused the contract: the object gate. + expect(reachOf(report, 'pwr_contract', 'position:pwr_rep', 'own')).toMatchObject({ masterEdit: 'editable', grants: { edit: false, delete: false, create: false } }); + expect(shape(report, 'pwr_contract', 'position:pwr_rep', 'own')).toEqual(ALL_REFUSED); + expect(shape(report, 'pwr_quote', 'position:pwr_rep', 'own')).toEqual(NO_DELETE); + }); + + it("finds today's engine refusing a two-level detail whose master the persona edits", () => { + // ADR-0055's amendment: the write gate walks the chain hop by hop, and a + // detail whose whole chain is reachable stays writable. Measured on this + // engine, a quote line is refused for every persona, its own chain + // included, while the same persona's empty update of the quote is admitted. + // This flips to `proven` the day the chain walk agrees with the quote's own write. + const r = resultFor(report, 'pwr_quote_line'); + expect(r.status).toBe('contradicted'); + for (const masters of ['other', 'own'] as const) { + const reach = reachOf(report, 'pwr_quote_line', 'master-all-records', masters); + expect(reach.masterEdit).toBe('editable'); + expect(reach.probes.map((p) => p.outcome)).toEqual(['contradicted', 'contradicted', 'contradicted', 'contradicted', 'contradicted']); + for (const p of reach.probes) { + expect(p.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_quote_line' }); + expect(p.detail).toMatch(/^the persona holds allow(Edit|Delete|Create) on pwr_quote_line and edited its master pwr_quote, and the (edit|delete|create) was refused/); + } + // Where the conjunction refuses, the refusal still holds. + expect(shape(report, 'pwr_quote_line', 'detail-only', masters)).toEqual(ALL_REFUSED); + } + expect(formatParentWriteReachReport(report)).toContain('✗ pwr_quote_line (pwr_quote_line.quote → pwr_quote, pwr_quote.account → pwr_account) CONTRADICTED'); + }); + + it('counts every reach and write, and the contradicted chain is its one hard failure', () => { + expect(report.summary).toEqual({ + objects: 3, + probed: 3, + proven: 2, + contradicted: 1, + failed: 0, + inconclusive: 0, + excluded: 0, + notDerivable: 0, + personas: { run: 5, notRun: 0 }, + // 3 objects × 5 personas × 2 master chains, the rep's three chains another principal owns outside its reach. + reaches: { probed: 27, unreadable: 3, unmeasured: 0 }, + probes: { edit: 54, delete: 54, create: 27 }, + held: 104, + contradictions: 31, + unattributed: 0, + faults: 0, + notRun: 0, + hardFailures: 1, + }); + }); +}); + +/** + * The negative and attribution controls. `pwc_signed`'s hooks refuse every + * edit and delete with the platform's own envelope (`403 PERMISSION_DENIED`, + * `details` naming the write), whoever writes and whatever the master says; + * `pwc_frozen`'s hook refuses every edit of the MASTER that way, so its edit + * reads refused while nothing stops a write of its details; `pwc_locked`'s + * refuse an edit with that envelope naming ANOTHER object, and a delete with + * the app's own code. + */ +const deny = (code: string, status: number, details?: { operation: string; object: string }) => async () => { + throw Object.assign(new Error(`refused by the app (${code})`), { code, status, ...(details ? { details } : {}) }); +}; +const controlSource = { + manifest: { id: 'com.example.parent-write-reach-controls', namespace: 'pwc', version: '0.0.1', type: 'app', name: 'Parent Write Reach Controls' }, + objects: [ + { name: 'pwc_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { + name: 'pwc_signed', + label: 'Signed', + sharingModel: 'controlled_by_parent', + fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwc_account' }, title: { type: 'text', label: 'Title' } }, + }, + { + name: 'pwc_locked', + label: 'Locked', + sharingModel: 'controlled_by_parent', + fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwc_account' }, title: { type: 'text', label: 'Title' } }, + }, + { name: 'pwc_frozen', label: 'Frozen', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { + name: 'pwc_frozen_line', + label: 'Frozen line', + sharingModel: 'controlled_by_parent', + fields: { frozen: { type: 'master_detail', label: 'Frozen', reference: 'pwc_frozen' }, title: { type: 'text', label: 'Title' } }, + }, + ], + hooks: [ + // Update and delete only: the fixtures are seeded through a door that + // runs hooks, so an insert refusal would refuse the fixtures too. + { + name: 'pwc_signed_refuses_an_edit', + object: 'pwc_signed', + events: ['beforeUpdate'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_signed' }), + }, + { + name: 'pwc_signed_refuses_a_delete', + object: 'pwc_signed', + events: ['beforeDelete'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'delete', object: 'pwc_signed' }), + }, + { + name: 'pwc_locked_refuses_an_edit_naming_the_account', + object: 'pwc_locked', + events: ['beforeUpdate'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_account' }), + }, + { + name: 'pwc_frozen_refuses_every_edit', + object: 'pwc_frozen', + events: ['beforeUpdate'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_frozen' }), + }, + { + name: 'pwc_locked_refuses_a_delete_with_its_own_code', + object: 'pwc_locked', + events: ['beforeDelete'], + handler: deny('RECORD_LOCKED', 409), + }, + ], +}; + +describe('runParentWriteReachProofs — NEGATIVE and ATTRIBUTION controls', () => { + let stack: VerifyStack; + let report: ParentWriteReachReport; + const app = defineStack(controlSource as never) as unknown as typeof controlSource; + + beforeAll(async () => { + stack = await bootStack(app as never, { security: parentWriteReachSecurity(app) }); + report = await runParentWriteReachProofs(stack, app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('NEGATIVE CONTROL: a detail refusing with the platform code where its master is editable is reported contradicted', () => { + const r = resultFor(report, 'pwc_signed'); + expect(r.status, formatParentWriteReachReport(report)).toBe('contradicted'); + for (const masters of ['other', 'own'] as const) { + expect(shape(report, 'pwc_signed', 'master-all-records', masters)).toEqual([ + 'edit/other allowed contradicted', 'delete/other allowed contradicted', + 'edit/own allowed contradicted', 'delete/own allowed contradicted', + 'create allowed held', + ]); + // Where the conjunction refuses too, the same refusal holds. + expect(shape(report, 'pwc_signed', 'detail-only', masters)).toEqual(ALL_REFUSED); + } + }); + + it('NEGATIVE CONTROL: details admitted under a master whose own edit is refused are reported contradicted', () => { + const r = resultFor(report, 'pwc_frozen_line'); + expect(r.status, formatParentWriteReachReport(report)).toBe('contradicted'); + for (const masters of ['other', 'own'] as const) { + const reach = reachOf(report, 'pwc_frozen_line', 'master-all-records', masters); + expect(reach).toMatchObject({ masterEdit: 'not-editable', masterAnswer: { code: 'PERMISSION_DENIED', object: 'pwc_frozen', operation: 'update' } }); + expect(shape(report, 'pwc_frozen_line', 'master-all-records', masters)).toEqual([ + 'edit/other refused contradicted', 'delete/other refused contradicted', + 'edit/own refused contradicted', 'delete/own refused contradicted', + 'create refused contradicted', + ]); + for (const p of reach.probes) expect(p.detail).toMatch(/was refused an edit of its master pwc_frozen, and the (edit|delete|create) was admitted$/); + } + // The two negative controls, and nothing else, are the run's hard failures. + expect(report.summary).toMatchObject({ contradicted: 2, failed: 0, hardFailures: 2 }); + }); + + it("ATTRIBUTION CONTROL: an app's own refusal is never read as the platform's, held or contradicted", () => { + const r = resultFor(report, 'pwc_locked'); + expect(r.status, formatParentWriteReachReport(report)).toBe('inconclusive'); + for (const masters of ['other', 'own'] as const) { + // Expected allowed, and refused — naming another object, or with the app's own code: unattributed. + expect(shape(report, 'pwc_locked', 'master-all-records', masters)).toEqual([ + 'edit/other allowed unattributed', 'delete/other allowed unattributed', + 'edit/own allowed unattributed', 'delete/own allowed unattributed', + 'create allowed held', + ]); + const probes = reachOf(report, 'pwc_locked', 'master-all-records', masters).probes; + for (const p of probes.filter((x) => x.verb === 'edit')) { + expect(p.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwc_account', operation: 'update' }); + expect(p.detail).toMatch(/^refused for another reason: PERMISSION_DENIED \(403\) naming update on pwc_account/); + } + for (const p of probes.filter((x) => x.verb === 'delete')) { + expect(p.answer).toMatchObject({ kind: 'refused', status: 409, code: 'RECORD_LOCKED' }); + expect(p.detail).toMatch(/^refused for another reason: RECORD_LOCKED \(409\)/); + } + } + expect(r.reaches.flatMap((x) => x.probes).some((p) => p.outcome === 'contradicted')).toBe(false); + }); +}); + +describe('runParentWriteReachProofs — a stack booted without parentWriteReachSecurity', () => { + let stack: VerifyStack; + let report: ParentWriteReachReport; + // The single-level objects only, so every probe this stack answers is one the engine agrees with. + const singleLevel = { + ...source, + objects: source.objects.filter((o) => o.name !== 'pwr_quote_line'), + permissions: source.permissions.map((set) => ({ + ...set, + objects: Object.fromEntries(Object.entries(set.objects).filter(([name]) => name !== 'pwr_quote_line')), + })), + }; + const app = defineStack(singleLevel as never) as unknown as typeof source; + + beforeAll(async () => { + stack = await bootStack(app as never); + report = await runParentWriteReachProofs(stack, app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('reports the verifier personas not run, with the reason, and counts them; the position personas still run', () => { + expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ + 'position:pwr_manager run', 'position:pwr_rep run', ...VERIFIERS.map((v) => `${v} not-run`), + ]); + for (const p of report.personas.filter((x) => x.kind === 'verifier')) { + expect(p.reason).toMatch(/the server does not resolve it for the persona: boot the stack with `parentWriteReachSecurity\(config\)`/); + } + expect(report.summary).toMatchObject({ proven: 2, contradicted: 0, personas: { run: 2, notRun: 3 }, hardFailures: 3 }); + expect(formatParentWriteReachReport(report)).toContain('⛔ persona master-all-records NOT RUN'); + }); +}); diff --git a/packages/verify/src/parent-write-reach.ts b/packages/verify/src/parent-write-reach.ts new file mode 100644 index 00000000000..560c6d698f8 --- /dev/null +++ b/packages/verify/src/parent-write-reach.ts @@ -0,0 +1,955 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The parent-derived write reach family: for every object a stack declares +// `controlled_by_parent`, a principal's write on a detail record is what +// ADR-0055 §3 says it is. +// +// ## The promise, and where each half of it is read from +// +// ADR-0055 §3: "editing/deleting/creating a detail requires **edit** access to +// its master", and its 2026-09-07 amendment: the write gate walks the chain hop +// by hop to the first master that governs its own rows. The detail's OWN +// object permission is the first door. So for a principal P, a detail record D +// under a master record M, and a verb (edit, delete, create), the expected +// answer is a conjunction: +// +// - P holds the verb on D's object — read from the server's effective object +// permissions for P (`GET /auth/me/permissions`, the one merge of the sets +// P holds, ADR-0124 D4), folded by the spec's `objectPermissionGrants`; +// - P can edit M — MEASURED, never re-derived: an empty update of M as P, +// which runs every write gate M has (the object gate, the record gate and, +// for a master that is itself controlled by its parent, the master gate +// above it) and changes no business value. +// +// Expected `allowed` iff both hold. ⛔ Nothing here resolves sharing, +// ownership, hierarchy or a chain: the master half is the engine's own answer +// on M, and the family's subject is that D's answer agrees with it. +// +// Which objects are in the population, and through which field, is the spec's +// own parse of the object (`ObjectSchema`): `sharingModel: +// 'controlled_by_parent'` and exactly one `master_detail` field (ADR-0055 §1). +// +// ## The door, and who walks through it +// +// The engine's write as the signed-in principal: the handle's `hooks.run` with +// `{ as: token }`, which makes the three calls the REST data ingress makes +// (`insert`, `update` by id, `delete` by id) with the caller's resolved +// context, the security middleware and the bound hooks included. It is the +// door, rather than the REST route itself, because only it keeps the refusal's +// own identity: the REST envelope names the ROUTE's object on every refusal +// (measured: a hook refusal naming another object comes back naming the +// route's) and carries no operation, so a hook's nested refusal on another +// object would read there as the detail's own. The engine's own write as the +// system principal bypasses authorization by design, so it is not in this +// family's population. +// +// The principals: three verifier personas whose permission sets this module +// authors from the app's own objects (registered at boot by +// `parentWriteReachSecurity`, granted by name, ADR-0131 D3/D4 — the RLS +// family's probe-persona path), and one persona per position the app declares +// (`provisionRlsPositionPersona`, whose whole capability is what the app binds +// to the position). Each persona meets two master chains: one another +// principal owns (so an all-records grant, not ownership, is what is +// exercised) and one the persona owns; and under each, a detail another +// principal owns and one the persona owns. +// +// ## Attribution is the control +// +// A refusal proves the conjunction held only when it is the platform's +// authorization refusal for this write: `PERMISSION_DENIED` with status 403 +// whose `details` name the detail's object and the probed operation +// (`update`, `delete`, `insert`). Any other refusal (validation, a hook's own +// code, a refusal on another object, a uniqueness collision, a missing field) +// is `unattributed`, never proven. +// +// Run it on a fresh stack of its own, booted with +// `parentWriteReachSecurity(config)`: it writes, deletes and signs up. + +import { ObjectSchema, referenceCarrierOf } from '@objectstack/spec/data'; +import { objectPermissionGrants, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { declaredCollection } from './artifact-collections.js'; +import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import type { VerifyStack } from './harness.js'; +import { declaredPositionNames, provisionRlsPositionPersona } from './rls.js'; + +/** + * ADR-0055's amendment: the write gate walks at most `CBP_MAX_CHAIN_DEPTH` + * (currently 8) controlled-by-parent masters and DENIES at the bound — a cost + * ceiling, not a supported chain length. A detail with that many + * controlled-by-parent masters above it is excluded: the engine refuses it by + * design there, whatever its master answers. + */ +const CHAIN_DEPTH_BOUND = 8; + +/** One `master_detail` link a write on a detail walks. */ +export interface ParentWriteReachHop { + /** The object holding the link. */ + object: string; + /** Its `master_detail` field (the field key is the FK column). */ + field: string; + /** The object that field references. */ + master: string; +} + +/** One object the app declares `controlled_by_parent`, and the masters a write on it walks. */ +export interface ParentWriteReachCase { + object: string; + /** + * The detail's own link first, then each master's own link while that + * master is itself `controlled_by_parent`. The last hop's master governs + * its own rows. + */ + hops: ParentWriteReachHop[]; + /** Why the declaration states no master this family can compare against. */ + excluded?: string; + /** Why no detail under its master chain can be built from the app's metadata. */ + notDerivable?: string; +} + +/** The verbs a detail write is probed with. */ +export type ParentWriteReachVerb = 'edit' | 'delete' | 'create'; + +/** Who owns a fixture record: another principal, or the probing persona. */ +export type ParentWriteReachOwner = 'other' | 'own'; + +/** What one write answered. */ +export interface ParentWriteReachAnswer { + /** `written` — the engine wrote; `refused` — a coded refusal; `faulted` — a 5xx, or a throw with no code. */ + kind: 'written' | 'refused' | 'faulted'; + status?: number; + code?: string; + /** The object and the operation the refusal's `details` name. */ + object?: string; + operation?: string; + detail?: string; +} + +/** What one detail write found. */ +export interface ParentWriteReachProbeResult { + verb: ParentWriteReachVerb; + /** Who owns the detail record written (a create writes a new one, owned by the persona). */ + detailOwner?: ParentWriteReachOwner; + /** The conjunction: the persona holds the verb on the detail's object AND edited the master. */ + expected: 'allowed' | 'refused'; + /** + * - `held` — the write met the conjunction; + * - `contradicted` — it met the other answer (a hard failure; see `detail`); + * - `unattributed` — refused, but not by the platform's authorization refusal for this detail; + * - `failed` — the door faulted; + * - `not-run` — a fixture could not be written, or the record does not show what the door answered. + */ + outcome: 'held' | 'contradicted' | 'unattributed' | 'failed' | 'not-run'; + answer?: ParentWriteReachAnswer; + detail?: string; +} + +/** One persona against one master chain of one detail object. */ +export interface ParentWriteReachReach { + persona: string; + /** Who owns the master chain. */ + masters: ParentWriteReachOwner; + /** `unreadable` — the persona cannot read the master, so the detail is outside its reach (not probed). */ + masterRead: 'readable' | 'unreadable' | 'unmeasured'; + /** The measured half: an empty update of the master as the persona. */ + masterEdit?: 'editable' | 'not-editable' | 'unmeasured'; + masterAnswer?: ParentWriteReachAnswer; + /** The detail object's effective grants for the persona (`/auth/me/permissions`). */ + grants?: Record; + /** Why this reach has no probe. */ + reason?: string; + probes: ParentWriteReachProbeResult[]; +} + +/** + * - `proven` — every probe held; + * - `contradicted` — some probe met the other answer (a hard failure); + * - `failed` — a door faulted and none contradicted (a hard failure); + * - `inconclusive` — some probe was unattributed or could not run, a master's edit reach could not be measured, or no probe ran; + * - `excluded` — the declaration names no master this family can compare against (see `reason`); + * - `not-derivable` — no detail under its master chain can be built from the app's metadata (see `reason`). + */ +export type ParentWriteReachObjectStatus = 'proven' | 'contradicted' | 'failed' | 'inconclusive' | 'excluded' | 'not-derivable'; + +export interface ParentWriteReachObjectResult { + object: string; + hops: ParentWriteReachHop[]; + status: ParentWriteReachObjectStatus; + reason?: string; + reaches: ParentWriteReachReach[]; +} + +/** A principal the family writes as. */ +export interface ParentWriteReachPersona { + /** `master-all-records`, `detail-only`, `master-all-records-no-delete`, or `position:`. */ + name: string; + kind: 'verifier' | 'position'; + label: string; + status: 'run' | 'not-run'; + reason?: string; + /** The permission sets the server resolved for it (`/auth/me/permissions`). */ + permissionSets?: string[]; +} + +export interface ParentWriteReachReport { + app: string; + /** The one door asked: the engine's write as the signed-in principal. The system door bypasses authorization by design and is not. */ + door: 'user'; + personas: ParentWriteReachPersona[]; + results: ParentWriteReachObjectResult[]; + summary: { + objects: number; + /** Objects probed (neither excluded nor not-derivable). */ + probed: number; + proven: number; + contradicted: number; + failed: number; + inconclusive: number; + excluded: number; + notDerivable: number; + personas: { run: number; notRun: number }; + /** Persona × master chain pairs: probed, outside the persona's read reach, or with an unmeasured master. */ + reaches: { probed: number; unreadable: number; unmeasured: number }; + /** Probes per verb. */ + probes: Record; + held: number; + contradictions: number; + unattributed: number; + faults: number; + notRun: number; + /** `contradicted + failed` objects, plus personas that could not be provisioned — the count a CI gate reads. */ + hardFailures: number; + }; +} + +const clip = (s: unknown, n = 200): string => String(s ?? '').slice(0, n); + +// ── The population ────────────────────────────────────────────────────────── + +/** The spec's parse of an object, or the reason it does not parse. */ +function parseObject(obj: any): { data?: any; reason?: string } { + const parsed = ObjectSchema.safeParse(obj); + if (parsed.success) return { data: parsed.data }; + const issue = parsed.error.issues[0]; + return { reason: `the object does not parse under the spec's ObjectSchema (${issue?.path.join('.') || 'object'}: ${clip(issue?.message, 120)})` }; +} + +/** A parsed object's one `master_detail` link, or why it states none. */ +function masterLinkOf(name: string, parsed: any): { hop?: ParentWriteReachHop; reason?: string } { + const links = Object.entries(parsed?.fields ?? {}).filter(([, f]) => f?.type === 'master_detail'); + if (links.length === 0) { + return { + reason: + `${name} declares no master_detail field, so its declaration names no master (ADR-0055 §1: exactly one); ` + + "a master the runtime would derive through a required lookup is the runtime's own precedence, not compared here", + }; + } + if (links.length > 1) { + return { + reason: + `${name} declares ${links.length} master_detail fields (${links.map(([k]) => k).join(', ')}), so which master ` + + 'governs it is not a fact the declaration states (ADR-0055 §1: exactly one)', + }; + } + const [field, def] = links[0]!; + let master: string | undefined; + try { + master = referenceCarrierOf({ reference: def?.reference }, 'verify parent write reach') ?? undefined; + } catch (e) { + return { reason: `${name}.${field}: ${clip((e as Error)?.message, 160)}` }; + } + return master ? { hop: { object: name, field, master } } : { reason: `${name}.${field} names no master object` }; +} + +/** + * Derive one case per object the app declares `controlled_by_parent`, as the + * spec's parse of the object states it, with the chain of masters a write on + * it walks. An object the family cannot compare is still returned, + * `excluded` or `notDerivable`, with the reason, so the report counts it. + */ +export function deriveParentWriteReachCases(config: any): ParentWriteReachCase[] { + const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); + const byName = new Map(); + for (const obj of declaredCollection(config, 'objects')) if (obj?.name) byName.set(obj.name, obj); + const parsed = new Map(); + const parse = (name: string) => { + if (!parsed.has(name)) parsed.set(name, parseObject(byName.get(name))); + return parsed.get(name)!; + }; + + const cases: ParentWriteReachCase[] = []; + for (const [name, obj] of byName) { + const own = parse(name); + if (own.reason) { + if (obj?.sharingModel === 'controlled_by_parent') cases.push({ object: name, hops: [], excluded: own.reason }); + continue; + } + if (own.data.sharingModel !== 'controlled_by_parent') continue; + + const c: ParentWriteReachCase = { object: name, hops: [] }; + const visited = new Set([name]); + let current = name; + let currentParsed = own.data; + for (;;) { + const link = masterLinkOf(current, currentParsed); + if (!link.hop) { + c.excluded = current === name ? link.reason : `its master ${current} is controlled_by_parent, and ${link.reason}`; + break; + } + c.hops.push(link.hop); + const master = link.hop.master; + if (!byName.has(master)) { + c.excluded = `the master ${master} is not an object this app declares, so no master record can be built from its metadata`; + break; + } + const m = parse(master); + if (m.reason) { + c.excluded = `the master ${master}: ${m.reason}`; + break; + } + if (m.data.sharingModel !== 'controlled_by_parent') break; + if (visited.has(master)) { + c.excluded = `the master chain re-enters ${master} (a cycle): the engine's write gate refuses by its cycle guard, whatever a master answers`; + break; + } + visited.add(master); + if (c.hops.length >= CHAIN_DEPTH_BOUND) { + c.excluded = + `the master chain reaches ${CHAIN_DEPTH_BOUND} controlled_by_parent masters, the write gate's depth bound ` + + "(ADR-0055's amendment), where it refuses by design whatever a master answers"; + break; + } + current = master; + currentParsed = m.data; + } + if (!c.excluded) { + for (const objectName of [name, ...c.hops.map((h) => h.master)]) { + const blocked = crud.get(objectName)?.blocked; + if (blocked) { + c.notDerivable = objectName === name + ? `the detail cannot be synthesized: ${blocked}` + : `the master ${objectName} cannot be synthesized: ${blocked}`; + break; + } + } + } + cases.push(c); + } + return cases; +} + +// ── The verifier personas ─────────────────────────────────────────────────── + +/** The three verifier personas: the set each holds, and the email it signs up with. */ +const VERIFIER_PERSONAS = [ + { name: 'master-all-records', set: 'verify_parent_write_master_all', email: 'verify-parent-write-master-all@objectstack.test' }, + { name: 'detail-only', set: 'verify_parent_write_detail_only', email: 'verify-parent-write-detail-only@objectstack.test' }, + { name: 'master-all-records-no-delete', set: 'verify_parent_write_no_delete', email: 'verify-parent-write-no-delete@objectstack.test' }, +] as const; +const VERIFIER_PASSWORD = 'Parent-Write-Probe-123'; +const OWNER_EMAIL = 'verify-parent-write-owner@objectstack.test'; + +/** + * The verifier personas' permission sets, authored from the app's own + * objects. A DETAIL is every probed object and every master above it that is + * itself controlled by its parent (its rows' access is derived, so it is a + * detail of the next master); a ROOT is the master each chain ends at, which + * governs its own rows. + * + * - `verify_parent_write_master_all` — the root by the all-records grant + * (`modifyAllRecords`), the details read / create / edit / delete: every + * write is expected allowed. + * - `verify_parent_write_detail_only` — the root readable (`viewAllRecords`) + * but not editable, the details read / create / edit / delete: every write + * is expected refused, by the master. + * - `verify_parent_write_no_delete` — the root by the all-records grant, the + * details read / create / edit: an edit and a create are expected allowed, + * and a delete refused by the detail's own permission (the master's + * all-records grant widens which rows, never which verbs). + */ +function parentWriteReachPermissionSets(config: any): PermissionSet[] { + const details = new Set(); + const roots = new Set(); + for (const c of deriveParentWriteReachCases(config)) { + if (c.excluded || c.notDerivable) continue; + details.add(c.object); + c.hops.forEach((h, i) => (i === c.hops.length - 1 ? roots.add(h.master) : details.add(h.master))); + } + const set = (name: string, label: string, detail: Record, root: Record) => + ({ + name, + label, + objects: { + ...Object.fromEntries([...details].map((o) => [o, { ...detail }])), + ...Object.fromEntries([...roots].map((o) => [o, { ...root }])), + }, + }) as unknown as PermissionSet; + const all = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + const modifyAll = { allowRead: true, allowEdit: true, modifyAllRecords: true }; + return [ + set(VERIFIER_PERSONAS[0].set, 'Verify parent write: master by all-records grant', all, modifyAll), + set(VERIFIER_PERSONAS[1].set, 'Verify parent write: detail only', all, { allowRead: true, viewAllRecords: true }), + set(VERIFIER_PERSONAS[2].set, 'Verify parent write: master by all-records grant, no detail delete', { + allowRead: true, allowCreate: true, allowEdit: true, + }, modifyAll), + ]; +} + +/** + * The `SecurityPlugin` a parent-write-reach boot needs: the platform defaults, + * the app's own declared default profile, and the three verifier personas' + * sets. Registered at boot because a set's definition comes from the registry + * alone (ADR-0131 D3/D4), exactly as `rlsProbeSecurity` registers the RLS + * probe's set; a stack booted without it runs the position personas only and + * reports the verifier personas not run. + */ +export function parentWriteReachSecurity(config: any): SecurityPlugin { + return new SecurityPlugin({ + ...(appSecurityPluginOptions(config) ?? {}), + defaultPermissionSets: [...securityDefaultPermissionSets, ...parentWriteReachPermissionSets(config)], + }); +} + +// ── The door ──────────────────────────────────────────────────────────────── + +/** The server's effective permissions for the caller: `GET /auth/me/permissions`, or why it gave none. */ +async function effectivePermissions(stack: VerifyStack, token: string): Promise<{ objects?: Record; sets: string[]; reason?: string }> { + try { + const res = await stack.apiAs(token, 'GET', '/auth/me/permissions'); + const payload = (await res.json().catch(() => undefined)) as { objects?: unknown; permissionSets?: unknown } | undefined; + const sets = Array.isArray(payload?.permissionSets) ? payload!.permissionSets.map(String) : []; + if (res.status !== 200 || !payload?.objects || typeof payload.objects !== 'object') { + return { sets, reason: `GET /auth/me/permissions answered ${res.status}: no effective grants to read` }; + } + // The endpoint answers this same empty body when it could not compute the + // caller's grants, so "no set at all" is not read as "no grant anywhere". + if (sets.length === 0) { + return { sets, reason: 'GET /auth/me/permissions resolved no permission set for the persona, which is also its answer when it could not compute one: no effective grants to read' }; + } + return { objects: payload.objects as Record, sets }; + } catch (e) { + return { sets: [], reason: `GET /auth/me/permissions faulted: ${clip((e as Error)?.message ?? e)}` }; + } +} + +/** One write through the engine as the signed-in principal, and what it answered. */ +async function write( + stack: VerifyStack, + token: string, + object: string, + operation: 'insert' | 'update' | 'delete', + input: Record, +): Promise { + try { + const answered = (await stack.hooks.run(object, operation, input, { as: token })) as { id?: unknown } | boolean | undefined; + const id = typeof answered === 'object' && answered?.id != null ? String(answered.id) : undefined; + return { kind: 'written', ...(id ? { id } : {}) }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown; details?: any; message?: unknown }; + const status = typeof err?.status === 'number' ? err.status : typeof err?.statusCode === 'number' ? err.statusCode : undefined; + if (typeof err?.code !== 'string' || (status !== undefined && status >= 500)) { + return { kind: 'faulted', ...(status !== undefined ? { status } : {}), detail: clip(err?.message ?? e) }; + } + return { + kind: 'refused', + code: err.code, + ...(status !== undefined ? { status } : {}), + ...(typeof err.details?.object === 'string' ? { object: err.details.object } : {}), + ...(typeof err.details?.operation === 'string' ? { operation: err.details.operation } : {}), + detail: clip(err.message), + }; + } +} + +/** The platform's authorization refusal of `operation` on `object`. */ +const deniedFor = (answer: ParentWriteReachAnswer, object: string, operation: string): boolean => + answer.kind === 'refused' && answer.code === 'PERMISSION_DENIED' && answer.status === 403 + && answer.object === object && answer.operation === operation; + +const plain = (a: ParentWriteReachAnswer): ParentWriteReachAnswer => ({ + kind: a.kind, + ...(a.status !== undefined ? { status: a.status } : {}), + ...(a.code !== undefined ? { code: a.code } : {}), + ...(a.object !== undefined ? { object: a.object } : {}), + ...(a.operation !== undefined ? { operation: a.operation } : {}), + ...(a.detail !== undefined && a.kind !== 'written' ? { detail: a.detail } : {}), +}); + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const DAY_MS = 86_400_000; + +/** A `date` / `datetime` value moved `days` later, in the same spelling. */ +function laterBy(value: string, days: number): string { + const at = Date.parse(value); + if (Number.isNaN(at) || days === 0) return value; + const moved = new Date(at + days * DAY_MS).toISOString(); + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; +} + +/** The `k`-th distinct value of a sample `base`. */ +function vary(base: string | number, k: number): string | number { + if (typeof base === 'number') return base + k; + if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); + const at = base.indexOf('@'); + if (at > 0) return `${base.slice(0, at)}-p${k}${base.slice(at)}`; + if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/p${k}`; + if (/^\+\d+$/.test(base)) return `${base}${k}`; + return `${base}-p${k}`; +} + +/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */ +function uniqueFields(objectDef: any): Set { + const out = new Set(); + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.unique !== undefined && def?.unique !== false) out.add(name); + } + for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { + if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); + } + return out; +} + +/** + * The smallest record of `objectDef` the CRUD derivation can offer (the + * unique-scope family's fixture shape): its values for the fields the object + * REQUIRES, so every optional field takes the app's own default. Each `date` / + * `datetime` is one day after the one declared before it, and each + * unique-constrained value is made distinct by `n`. + */ +function fixtureBody(body: Record, objectDef: any, n: number): Record { + const unique = uniqueFields(objectDef); + const out: Record = {}; + let dates = 0; + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.required !== true || !(name in body)) continue; + let value = body[name]; + if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); + if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n); + out[name] = value; + } + return out; +} + +// ── The verdict ───────────────────────────────────────────────────────────── + +/** The grant key a verb is read from on the detail's effective permission. */ +const GRANT: Record = { + edit: 'allowEdit', + delete: 'allowDelete', + create: 'allowCreate', +}; + +/** The engine operation each verb is written as, and named by in a refusal's `details`. */ +const OPERATION: Record = { + edit: 'update', + delete: 'delete', + create: 'insert', +}; + +/** + * Compare one detail write against the conjunction. The whole verdict of the + * family is this function: it reads the expectation, the door's answer and + * whether the record shows the write, and nothing else. + */ +function judge( + c: ParentWriteReachCase, + probe: { verb: ParentWriteReachVerb; expected: 'allowed' | 'refused'; why: string }, + answer: ParentWriteReachAnswer, + /** Whether the record shows the write (`undefined` when it cannot say). */ + landed: boolean | undefined, +): Pick { + if (answer.kind === 'faulted') return { outcome: 'failed', detail: `the door faulted: ${answer.status ?? ''} ${answer.detail ?? ''}` }; + if (answer.kind === 'refused') { + if (!deniedFor(answer, c.object, OPERATION[probe.verb])) { + return { + outcome: 'unattributed', + detail: + `refused for another reason: ${answer.code}${answer.status ? ` (${answer.status})` : ''}` + + `${answer.object || answer.operation ? ` naming ${answer.operation ?? '?'} on ${answer.object ?? '?'}` : ''} ${answer.detail ?? ''}`, + }; + } + if (landed === true) return { outcome: 'contradicted', detail: `the ${probe.verb} was refused, yet the record shows it landed` }; + if (probe.expected === 'refused') return { outcome: 'held' }; + return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was refused (403 PERMISSION_DENIED)` }; + } + if (landed === false) return { outcome: 'not-run', detail: `the engine answered the ${probe.verb} written, but the record does not show it` }; + if (probe.expected === 'allowed') return { outcome: 'held' }; + return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was admitted` }; +} + +function objectStatus(reaches: ParentWriteReachReach[]): { status: ParentWriteReachObjectStatus; reason?: string } { + const probes = reaches.flatMap((r) => r.probes); + if (probes.some((p) => p.outcome === 'contradicted')) return { status: 'contradicted' }; + if (probes.some((p) => p.outcome === 'failed')) return { status: 'failed' }; + const open = probes.filter((p) => p.outcome === 'unattributed' || p.outcome === 'not-run'); + const unmeasured = reaches.filter((r) => r.masterRead === 'unmeasured' || r.masterEdit === 'unmeasured'); + if (open.length > 0 || unmeasured.length > 0 || probes.length === 0) { + const first = open[0]?.detail ?? unmeasured[0]?.reason; + return { + status: 'inconclusive', + reason: probes.length === 0 && unmeasured.length === 0 + ? 'no probe could be run: no persona reads a master of this object' + : `${open.length} of ${probes.length} write(s) unattributed or not run, ${unmeasured.length} master(s) unmeasured: ${clip(first, 160)}`, + }; + } + return { status: 'proven' }; +} + +// ── The run ───────────────────────────────────────────────────────────────── + +interface RunningPersona extends ParentWriteReachPersona { + token: string; + userId: string; + /** The effective object permissions the server answered for it. */ + objects: Record; +} + +/** + * Run the parent-derived write reach family against a live stack: every + * persona (the three verifier personas, then one per declared position) + * writes every probed detail object's records through the engine as itself — + * edit, delete and create — under a master chain another principal owns and + * one it owns, and each answer is compared with the conjunction. Returns a + * structured report; a per-object failure is collected, not thrown. + */ +export async function runParentWriteReachProofs(stack: VerifyStack, config: any): Promise { + const cases = deriveParentWriteReachCases(config); + const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); + const objectDefs = new Map(); + for (const o of declaredCollection(config, 'objects')) if (o?.name) objectDefs.set(o.name, o); + const hasOwnerColumn = (object: string): boolean => { + try { + return 'owner_id' in ((stack.metadata.object(object) as any)?.fields ?? {}); + } catch { + return false; + } + }; + + // ── The principals ──────────────────────────────────────────────────────── + const ownerToken = await stack.signUp(OWNER_EMAIL); + const ownerId = String((await stack.contextFor(ownerToken)).userId); + const personas: RunningPersona[] = []; + const notRun: ParentWriteReachPersona[] = []; + + const admit = async (base: ParentWriteReachPersona, token: string): Promise => { + const userId = String((await stack.contextFor(token)).userId); + const me = await effectivePermissions(stack, token); + if (!me.objects) { + notRun.push({ ...base, status: 'not-run', reason: me.reason }); + return; + } + personas.push({ ...base, status: 'run', permissionSets: me.sets, token, userId, objects: me.objects }); + }; + + for (const v of VERIFIER_PERSONAS) { + const base: ParentWriteReachPersona = { name: v.name, kind: 'verifier', label: v.email, status: 'run' }; + try { + const first = await stack.signUp(v.email, VERIFIER_PASSWORD); + const userId = (await stack.contextFor(first)).userId; + // [ADR-0131 D4] The grant names its set; the set itself is registered at + // boot by `parentWriteReachSecurity`, never written as a row. + await stack.seed('sys_user_permission_set', [{ user_id: userId, permission_set: v.set }]); + const token = await stack.signIn(v.email, VERIFIER_PASSWORD); + const me = await effectivePermissions(stack, token); + if (!me.sets.includes(v.set)) { + notRun.push({ + ...base, + status: 'not-run', + reason: + `the grant names ${v.set}, and the server does not resolve it for the persona: boot the stack with ` + + '`parentWriteReachSecurity(config)`, which registers it (a grant naming an unregistered set confers nothing)', + }); + continue; + } + await admit(base, token); + } catch (e) { + notRun.push({ ...base, status: 'not-run', reason: clip((e as Error)?.message ?? e) }); + } + } + for (const position of declaredPositionNames(config)) { + const email = `verify-parent-write-pos-${position.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}@objectstack.test`; + const base: ParentWriteReachPersona = { name: `position:${position}`, kind: 'position', label: email, status: 'run' }; + try { + const p = await provisionRlsPositionPersona(stack, position, { email }); + await admit(base, p.token); + } catch (e) { + notRun.push({ ...base, status: 'not-run', reason: clip((e as Error)?.message ?? e) }); + } + } + + // ── Fixtures: through the seed door, as system, owned as each probe needs ── + let counter = 0; + const bases = new Map(); + + async function seedRecord(object: string, overrides: Record, owner: string): Promise<{ id?: string; reason?: string }> { + const c = crud.get(object); + if (!c || c.blocked) return { reason: c?.blocked ?? `object ${object} is not derived` }; + const ids = new Map(); + for (const ref of c.relationalRefs ?? []) { + if (!ref.required || ref.field in overrides) continue; + const dep = await baseRecord(ref.target); + if (!dep.id) return { reason: `required relation ${ref.field} → ${ref.target}: ${dep.reason}` }; + ids.set(ref.target, dep.id); + } + const refs = (c.relationalRefs ?? []).filter((r) => !(r.field in overrides)); + const { body, missing } = fillRelationalRefs({ ...c, relationalRefs: refs }, ids); + if (missing) return { reason: missing }; + const row = { ...fixtureBody(body, objectDefs.get(object), ++counter), ...overrides, ...(hasOwnerColumn(object) ? { owner_id: owner } : {}) }; + try { + const [written] = await stack.seed(object, [row]); + return written?.id != null ? { id: String(written.id) } : { reason: `the seed of ${object} returned no id` }; + } catch (e) { + const err = e as { code?: unknown; message?: unknown }; + return { reason: `the seed of ${object} was refused: ${typeof err?.code === 'string' ? `${err.code} ` : ''}${clip(err?.message, 160)}` }; + } + } + + async function baseRecord(object: string): Promise<{ id?: string; reason?: string }> { + const known = bases.get(object); + if (known) return known; + bases.set(object, { reason: `the base ${object} record is being created` }); + const made = await seedRecord(object, {}, ownerId); + bases.set(object, made); + return made; + } + + /** The master chain, root first, every record owned by `owner`; returns the immediate master's id. */ + async function seedChain(c: ParentWriteReachCase, owner: string): Promise<{ id?: string; reason?: string }> { + let parent: string | undefined; + for (let i = c.hops.length - 1; i >= 0; i--) { + const object = c.hops[i]!.master; + const above = c.hops[i + 1]; + const made = await seedRecord(object, above ? { [above.field]: parent } : {}, owner); + if (!made.id) return { reason: `the master chain's ${object} record: ${made.reason}` }; + parent = made.id; + } + return { id: parent }; + } + + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]; + + // ── Probes ──────────────────────────────────────────────────────────────── + const results: ParentWriteReachObjectResult[] = []; + for (const c of cases) { + if (c.excluded) { + results.push({ object: c.object, hops: c.hops, status: 'excluded', reason: c.excluded, reaches: [] }); + continue; + } + if (c.notDerivable) { + results.push({ object: c.object, hops: c.hops, status: 'not-derivable', reason: c.notDerivable, reaches: [] }); + continue; + } + const { field, master } = c.hops[0]!; + const root = c.hops[c.hops.length - 1]!.master; + const detailOwnerColumn = hasOwnerColumn(c.object); + const reaches: ParentWriteReachReach[] = []; + const othersChain = await seedChain(c, ownerId); + + for (const p of personas) { + const entry = p.objects?.[c.object]; + const grants: Record = { + edit: objectPermissionGrants(entry, GRANT.edit), + delete: objectPermissionGrants(entry, GRANT.delete), + create: objectPermissionGrants(entry, GRANT.create), + }; + for (const masters of ['other', 'own'] as const) { + const reach: ParentWriteReachReach = { persona: p.name, masters, masterRead: 'unmeasured', probes: [] }; + reaches.push(reach); + if (masters === 'own' && !hasOwnerColumn(root)) { + reach.reason = `${root} has no owner column, so no principal owns a master chain of ${c.object}`; + reach.masterRead = 'unreadable'; + continue; + } + const chain = masters === 'other' ? othersChain : await seedChain(c, p.userId); + if (!chain.id) { + reach.reason = chain.reason; + continue; + } + const masterId = chain.id; + + // The measured half: can this persona read, then edit, the master? + let readable: boolean | undefined; + try { + readable = (await stack.rows(master, { id: masterId }, { as: p.token })).length > 0; + } catch (e) { + const err = e as { code?: unknown; message?: unknown }; + if (err?.code === 'PERMISSION_DENIED') readable = false; + else reach.reason = `the read of ${master} as the persona faulted: ${clip(err?.message ?? e, 160)}`; + } + if (readable === undefined) continue; + if (!readable) { + reach.masterRead = 'unreadable'; + reach.reason = + `the persona's read of ${master} returns no row: the master is outside its read reach, so its details are ` + + "too (a write to a record the caller cannot read is the RLS family's subject)"; + continue; + } + reach.masterRead = 'readable'; + const edit = await write(stack, p.token, master, 'update', { id: masterId }); + reach.masterAnswer = plain(edit); + reach.masterEdit = edit.kind === 'written' ? 'editable' : deniedFor(edit, master, 'update') ? 'not-editable' : 'unmeasured'; + reach.grants = grants; + if (reach.masterEdit === 'unmeasured') { + reach.reason = `the empty update of ${master} answered ${edit.code ?? edit.kind}, not a write gate's verdict on it: its edit reach could not be measured`; + continue; + } + + const expect = (verb: ParentWriteReachVerb) => { + const holds = grants[verb]; + const editable = reach.masterEdit === 'editable'; + return { + verb, + expected: holds && editable ? ('allowed' as const) : ('refused' as const), + why: + `the persona ${holds ? 'holds' : 'lacks'} ${GRANT[verb]} on ${c.object} and ` + + `${editable ? 'edited' : 'was refused an edit of'} its master ${master}`, + }; + }; + + for (const detailOwner of detailOwnerColumn ? (['other', 'own'] as const) : (['other'] as const)) { + const d = await seedRecord(c.object, { [field]: masterId }, detailOwner === 'own' ? p.userId : ownerId); + if (!d.id) { + for (const verb of ['edit', 'delete'] as const) { + const e = expect(verb); + reach.probes.push({ verb, detailOwner, expected: e.expected, outcome: 'not-run', detail: `the detail record: ${d.reason}` }); + } + continue; + } + { + const e = expect('edit'); + const before = await row(c.object, d.id); + const answer = await write(stack, p.token, c.object, 'update', { id: d.id }); + const after = await row(c.object, d.id); + const stamp = before && 'updated_at' in before ? after?.updated_at !== before.updated_at : undefined; + reach.probes.push({ verb: 'edit', detailOwner, expected: e.expected, answer: plain(answer), ...judge(c, e, answer, stamp) }); + } + { + const e = expect('delete'); + const answer = await write(stack, p.token, c.object, 'delete', { id: d.id }); + const gone = !(await row(c.object, d.id)); + reach.probes.push({ verb: 'delete', detailOwner, expected: e.expected, answer: plain(answer), ...judge(c, e, answer, gone) }); + } + } + { + const e = expect('create'); + const cc = crud.get(c.object)!; + const ids = new Map(); + let reason: string | undefined; + for (const ref of cc.relationalRefs ?? []) { + if (!ref.required || ref.field === field) continue; + const dep = await baseRecord(ref.target); + if (!dep.id) { + reason = `required relation ${ref.field} → ${ref.target}: ${dep.reason}`; + break; + } + ids.set(ref.target, dep.id); + } + if (reason) { + reach.probes.push({ verb: 'create', expected: e.expected, outcome: 'not-run', detail: reason }); + } else { + const refs = (cc.relationalRefs ?? []).filter((r) => r.field !== field); + const { body } = fillRelationalRefs({ ...cc, relationalRefs: refs }, ids); + const answer = await write(stack, p.token, c.object, 'insert', { + ...fixtureBody(body, objectDefs.get(c.object), ++counter), + [field]: masterId, + }); + const made = answer.kind === 'written' && answer.id ? await row(c.object, answer.id) : undefined; + const landed = answer.kind === 'written' ? made !== undefined && String(made?.[field]) === masterId : undefined; + reach.probes.push({ verb: 'create', expected: e.expected, answer: plain(answer), ...judge(c, e, answer, landed) }); + } + } + } + } + results.push({ object: c.object, hops: c.hops, ...objectStatus(reaches), reaches }); + } + + const all = results.flatMap((r) => r.reaches.flatMap((x) => x.probes)); + const allReaches = results.flatMap((r) => r.reaches); + const count = (s: ParentWriteReachObjectStatus) => results.filter((r) => r.status === s).length; + const reported: ParentWriteReachPersona[] = [ + ...personas.map((p) => ({ + name: p.name, + kind: p.kind, + label: p.label, + status: p.status, + ...(p.permissionSets ? { permissionSets: p.permissionSets } : {}), + })), + ...notRun, + ]; + return { + app: config?.manifest?.id ?? config?.manifest?.namespace ?? 'app', + door: 'user', + personas: reported, + results, + summary: { + objects: results.length, + probed: results.filter((r) => r.status !== 'excluded' && r.status !== 'not-derivable').length, + proven: count('proven'), + contradicted: count('contradicted'), + failed: count('failed'), + inconclusive: count('inconclusive'), + excluded: count('excluded'), + notDerivable: count('not-derivable'), + personas: { run: personas.length, notRun: notRun.length }, + reaches: { + probed: allReaches.filter((r) => r.probes.length > 0).length, + unreadable: allReaches.filter((r) => r.masterRead === 'unreadable').length, + unmeasured: allReaches.filter((r) => r.masterRead === 'unmeasured' || r.masterEdit === 'unmeasured').length, + }, + probes: { + edit: all.filter((p) => p.verb === 'edit').length, + delete: all.filter((p) => p.verb === 'delete').length, + create: all.filter((p) => p.verb === 'create').length, + }, + held: all.filter((p) => p.outcome === 'held').length, + contradictions: all.filter((p) => p.outcome === 'contradicted').length, + unattributed: all.filter((p) => p.outcome === 'unattributed').length, + faults: all.filter((p) => p.outcome === 'failed').length, + notRun: all.filter((p) => p.outcome === 'not-run').length, + hardFailures: count('contradicted') + count('failed') + notRun.length, + }, + }; +} + +/** Pretty one-line-per-object summary for logs. */ +export function formatParentWriteReachReport(report: ParentWriteReachReport): string { + const lines: string[] = [`\n=== objectstack verify — parent-derived write reach — ${report.app} ===`]; + lines.push(' door: the engine write as each persona (insert, update and delete by id); the system door bypasses authorization and is not asked'); + const ran = report.personas.filter((p) => p.status === 'run'); + lines.push(` personas: ${ran.map((p) => p.name).join(', ') || 'none'}`); + for (const p of report.personas.filter((x) => x.status === 'not-run')) { + lines.push(` ⛔ persona ${p.name} NOT RUN — ${p.reason ?? ''}`); + } + for (const r of report.results) { + const chain = r.hops.length > 0 ? ` (${r.hops.map((h) => `${h.object}.${h.field} → ${h.master}`).join(', ')})` : ''; + const probes = r.reaches.flatMap((x) => x.probes.map((p) => ({ ...p, persona: x.persona, masters: x.masters }))); + if (r.status === 'proven') { + lines.push(` ✓ ${r.object}${chain} held on ${probes.length} write(s)`); + } else if (r.status === 'contradicted' || r.status === 'failed') { + const bad = probes.filter((p) => p.outcome === (r.status === 'contradicted' ? 'contradicted' : 'failed')); + lines.push(` ✗ ${r.object}${chain} ${r.status === 'contradicted' ? 'CONTRADICTED' : 'FAULTED'} on ${bad.length} of ${probes.length} write(s):`); + for (const p of bad.slice(0, 12)) { + lines.push( + ` ${p.persona}, masters ${p.masters === 'own' ? 'its own' : "another's"}, ${p.verb}` + + `${p.detailOwner ? ` (detail ${p.detailOwner === 'own' ? 'its own' : "another's"})` : ''}: ${clip(p.detail, 220)}`, + ); + } + if (bad.length > 12) lines.push(` … and ${bad.length - 12} more`); + } else if (r.status === 'inconclusive') { + lines.push(` ~ ${r.object}${chain} inconclusive: ${r.reason ?? ''}`); + } else { + lines.push(` – ${r.object}${chain} ${r.status}: ${r.reason ?? ''}`); + } + } + const s = report.summary; + lines.push( + ` ── ${s.proven} proven, ${s.contradicted} CONTRADICTED, ${s.failed} FAILED, ${s.inconclusive} inconclusive, ` + + `${s.excluded} excluded, ${s.notDerivable} not derivable ` + + `(${s.personas.run} persona(s) run, ${s.personas.notRun} not run; ${s.reaches.probed} reach(es) probed, ` + + `${s.reaches.unreadable} outside read reach, ${s.reaches.unmeasured} unmeasured; ` + + `writes: ${s.probes.edit} edit, ${s.probes.delete} delete, ${s.probes.create} create — ${s.held} held, ` + + `${s.contradictions} contradicted, ${s.unattributed} unattributed, ${s.faults} faulted, ${s.notRun} not run)`, + ); + return lines.join('\n'); +} diff --git a/packages/verify/src/unique-scope.test.ts b/packages/verify/src/unique-scope.test.ts index 25cc7a632b2..761b94d4ede 100644 --- a/packages/verify/src/unique-scope.test.ts +++ b/packages/verify/src/unique-scope.test.ts @@ -307,6 +307,63 @@ describe('runUniqueScopeProofs — NEGATIVE CONTROL, within: a declaration its d }); }); +describe('runUniqueScopeProofs — ATTRIBUTION CONTROL, a unique indexes[] entry over the probed column', () => { + // The field's own index cannot be created (the table already holds a + // duplicate when the declaration arrives, ADR-0120 D4), while a declared + // unique composite over the same column can: its other column takes one + // default on every fixture, so the composite refuses every second holder, + // naming no field, and admits the twin. A field-less refusal there is + // possibly that entry's, never proof of the field's own declaration. + const controlApp = (declared: boolean) => ({ + manifest: { id: 'com.example.unique-scope-composite', namespace: 'uqc', version: '0.0.1', type: 'app', name: 'Composite Control' }, + objects: [ + { + name: 'uqc_item', + label: 'Item', + fields: { + name: { type: 'text', label: 'Name', ...(declared ? { unique: true } : {}) }, + kind: { type: 'text', label: 'Kind', ...(declared ? { defaultValue: 'standard' } : {}) }, + }, + ...(declared ? { indexes: [{ fields: ['name', 'kind'], unique: 'organization' }] } : {}), + }, + ], + }); + let dir: string; + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'verify-unique-scope-composite-')); + const databaseFile = join(dir, 'db.sqlite'); + const before = await bootStack(defineStack(controlApp(false) as never) as never, { databaseFile }); + await before.seed('uqc_item', [{ name: 'held twice', kind: 'a' }, { name: 'held twice', kind: 'b' }]); + await before.stop(); + const app = defineStack(controlApp(true) as never) as never; + stack = await bootStack(app, { databaseFile }); + report = await runUniqueScopeProofs(stack, await stack.signIn(), app); + }, 2 * BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('never credits a field-less refusal to the field when a unique indexes[] entry covers its column', () => { + const r = resultFor(report, 'uqc_item', 'name'); + expect(r.status, JSON.stringify(r.probes)).toBe('inconclusive'); + expect(shape(report, 'uqc_item', 'name')).toEqual([ + 'within rest insert refused unattributed', 'within rest update refused unattributed', + 'within system insert refused unattributed', 'within system update refused unattributed', + ]); + for (const p of r.probes) { + expect(p.answer).toMatchObject({ kind: 'refused' }); + expect(p.answer!.field).toBeUndefined(); + expect(p.detail).toMatch(/^the unique refusal names no field, and a unique indexes\[\] entry \(name, kind\) covers name/); + } + expect(report.summary).toMatchObject({ proven: 0, inconclusive: 1, unattributed: 4, hardFailures: 0 }); + }); +}); + describe('runUniqueScopeProofs — NEGATIVE CONTROL, across: a per-organization field a global index overrides', () => { const controlApp = { manifest: { id: 'com.example.unique-scope-across', namespace: 'uqx', version: '0.0.1', type: 'app', name: 'Across Control' }, diff --git a/packages/verify/src/unique-scope.ts b/packages/verify/src/unique-scope.ts index fee9f33f4a0..c81e31ce5f2 100644 --- a/packages/verify/src/unique-scope.ts +++ b/packages/verify/src/unique-scope.ts @@ -61,6 +61,13 @@ // permission, a constraint on another field — is `unattributed`: an engine // that refused every write would otherwise prove every scope. // +// A refusal that names no field is never attributed where a unique +// `indexes[]` entry covers the probed column: that entry refuses the same +// second holder, naming no field either, whenever the fixtures hold its other +// columns constant, and its twin is admitted too. So there the refusal could +// be the entry's while the field's own constraint is absent, and it is +// `unattributed`. +// // Run it on a fresh stack of its own: it writes duplicates by design, and the // records it creates would collide with another family's on a unique field. @@ -371,6 +378,16 @@ function uniqueFields(objectDef: any): Set { return out; } +/** The columns of each unique `indexes[]` entry of `objectDef` that includes `field`. */ +function uniqueIndexesOver(objectDef: any, field: string): string[][] { + const out: string[][] = []; + for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { + const columns = Array.isArray(index?.fields) ? index.fields.map(String) : []; + if (index?.unique && columns.includes(field)) out.push(columns); + } + return out; +} + /** * The smallest record of `objectDef` the CRUD derivation can offer: its values * for the fields the object REQUIRES (required relations already filled), so @@ -485,6 +502,8 @@ interface UniqueScopeObservation { secondHolds?: boolean; /** When the second write was admitted: both records carry the same organization. */ sameOrganization?: boolean; + /** The unique `indexes[]` entries whose columns include the probed field. */ + coveringIndexes: string[][]; } /** @@ -508,6 +527,17 @@ function judge( if (answer.field !== undefined && answer.field !== c.field) { return { outcome: 'unattributed', detail: `refused by the unique constraint on another field (${answer.field})` }; } + // A unique `indexes[]` entry over this column refuses the same write, + // naming no field, and admits the twin: a field-less refusal is then + // possibly the entry's, whatever the field's own constraint does. + if (answer.field === undefined && o.coveringIndexes.length > 0) { + return { + outcome: 'unattributed', + detail: + `the unique refusal names no field, and a unique indexes[] entry (${o.coveringIndexes[0]!.join(', ')}) covers ` + + `${c.field}, so the refusal may be that entry's rather than the field's own declaration`, + }; + } // A refusal naming no field is this field's when the only change that // turns it into an admitted write is the probed value: an update changes // nothing else, and an insert's twin changes nothing else. @@ -699,6 +729,7 @@ export async function runUniqueScopeProofs( continue; } const source = valueSource(c.type, c.field, objectDefs.get(c.object)?.fields?.[c.field], c.object, crud.get(c.object)); + const coveringIndexes = uniqueIndexesOver(objectDefs.get(c.object), c.field); // Probe values draw on the fixtures' counter, so no probe value equals a fixture's. const fresh = async (w: Writer): Promise<{ value?: unknown; reason?: string }> => { if (source.base !== undefined) return { value: vary(source.base, ++variant) }; @@ -742,6 +773,7 @@ export async function runUniqueScopeProofs( const observation: UniqueScopeObservation = { answer: d.answer, ...(twin ? { twin } : {}), + coveringIndexes, holderHolds: holds(holderRow?.[c.field], h.value), ...(d.id ? { secondHolds: holds(secondRow?.[c.field], h.value), @@ -763,6 +795,7 @@ export async function runUniqueScopeProofs( const changed = await row(c.object, e.id); const observation: UniqueScopeObservation = { answer, + coveringIndexes, holderHolds: holds(holderRow?.[c.field], h.value), ...(answer.kind === 'written' ? { secondHolds: holds(changed?.[c.field], h.value), diff --git a/scripts/check-verify-stand-in-erasure.mjs b/scripts/check-verify-stand-in-erasure.mjs index aa664273167..8059a78e85b 100644 --- a/scripts/check-verify-stand-in-erasure.mjs +++ b/scripts/check-verify-stand-in-erasure.mjs @@ -230,6 +230,9 @@ const NOT_A_STAND_IN = { runUniqueScopeProofs: 'same `VerifyStack` handle as runCrudVerification.', formatUniqueScopeReport: 'formats a `UniqueScopeReport` this package produced; presentation, not conformance.', + runParentWriteReachProofs: 'same `VerifyStack` handle as runCrudVerification.', + formatParentWriteReachReport: + 'formats a `ParentWriteReachReport` this package produced; presentation, not conformance.', fillRelationalRefs: 'takes one `CrudCase` derived by `deriveCrudCases` — a data record this package built, with no ' + 'external implementer.',