diff --git a/docs/adr/0056-permission-model-landing-verification.md b/docs/adr/0056-permission-model-landing-verification.md index deb3eba76f..86efad5905 100644 --- a/docs/adr/0056-permission-model-landing-verification.md +++ b/docs/adr/0056-permission-model-landing-verification.md @@ -188,3 +188,39 @@ The one-time table above rots. Make it a **living ledger**: extend the ADR-0054 - ADR-0049 (no unenforced security properties), ADR-0054 (runtime proof), ADR-0055 (controlled-by-parent), ADR-0010 (metadata protection), ADR-0029 (kernel object ownership). - Audit evidence: `plugin-security/src/security-plugin.ts`, `rls-compiler.ts`; `plugin-sharing/src/sharing-service.ts`, `sharing-rule-service.ts`; `rest/src/rest-server.ts`; `runtime/src/security/resolve-execution-context.ts`; `spec/src/security/{permission,rls,sharing}.zod.ts`, `spec/src/data/object.zod.ts`. - Liveness ledger: `packages/spec/scripts/liveness/proof-registry.mts`. Implementation status: `content/docs/concepts/implementation-status.mdx`, `content/docs/guides/security.mdx`. + +--- + +## Implementation status (2026-06-20) + +The decisions landed incrementally, each with a runtime proof: + +| Decision | Status | Proof / artifact | +| :-- | :-- | :-- | +| OWD scenarios (private + public-read) | ✅ landed | `showcase-private-owd`, `showcase-public-read-owd` dogfood | +| D1 — canonical OWD vocabulary | ✅ landed | `plugin-sharing` units + OWD dogfood | +| D2 — anonymous deny (warn) | ✅ landed (warn) | `showcase-anonymous-deny` dogfood; **flip release-gated** | +| D4 — RLS compiler no-silent-drop | ✅ landed | `plugin-security` units | +| D6 — configurable role hierarchy | ✅ landed | `RoleGraphService` units (`role_and_subordinates`) | +| D7 — app-declared default profile | ✅ landed | `showcase-default-profile` dogfood | +| D8 — experimental-tag unenforced surface | ✅ landed | spec markers + liveness | +| **D10 — conformance matrix** | ✅ landed | `authz-conformance.matrix.ts` + `.test.ts` (CI-checked) | +| D6/D7 e2e showcase, requireAuth flip | follow-on | see below | + +### `requireAuth` default-flip readiness (pre-flip audit) + +Flipping the global `requireAuth` default to secure-by-default is **release-gated**. A +pre-flip audit of the legitimate anonymous surfaces found: + +- **Share links** — SAFE. `share-link-service.ts` validates the token then reads under a + **system context** (`SYSTEM_CTX`), so it does not depend on the anonymous fail-open and + survives a deny flip. +- **Control-plane** (`/auth`, `/health`, `/discovery`) — exempt via dispatcher skip-paths. +- **Public forms** — AT RISK. `/forms/:slug/submit` bypasses `enforceAuth` but the INSERT + relies on a `guest_portal` profile to scope it; `guest_portal` is **not built-in** (only + the CRM example defines one), so under a deny flip, public forms break unless the + deployment ships a `guest_portal` profile. + +**Pre-requisite for the flip:** make public-form submission self-authorizing (grant INSERT +on the form's declared target object) or ship a built-in `guest_portal`, then warn→enforce +with a migration note. Until then, D2's boot WARN is the landed state. diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts new file mode 100644 index 0000000000..bc10abdd31 --- /dev/null +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 D10 — Authorization Conformance Matrix. +// +// The durable encoding of the ADR-0056 audit: one row per authorization +// primitive, each in EXACTLY ONE honest state (enforced / experimental / +// removed). `enforced` rows name their runtime enforcement site; high-risk +// enforced rows additionally reference an end-to-end dogfood proof. The +// companion test (`authz-conformance.test.ts`) asserts the matrix is complete +// and that every referenced proof file exists — so "the permission model is +// landed" is a CHECKED artifact, not a one-time scan. A new fail-open (a +// declared-but-unenforced primitive) or a deleted proof breaks CI. + +export type AuthzState = 'enforced' | 'experimental' | 'removed'; + +export interface AuthzPrimitive { + id: string; + summary: string; + state: AuthzState; + /** Runtime enforcement site (required when state === 'enforced'). */ + enforcement?: string; + /** Dogfood proof filename in this directory (required for high-risk enforced). */ + proof?: string; + /** Why it is experimental/removed, or a roadmap pointer. */ + note?: string; +} + +export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ + // ── Enforced + end-to-end proven ─────────────────────────────────────── + { id: 'rls-read', summary: 'RLS `using` read filter', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts computeRlsFilter (AND-injected)', proof: 'rls-fixture.dogfood.test.ts' }, + { id: 'rls-by-id-write', summary: 'by-id write enforcement (#1994)', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts pre-image re-read', proof: 'rls-fixture.dogfood.test.ts' }, + { id: 'owd-private', summary: 'OWD private (owner-only)', state: 'enforced', + enforcement: 'plugin-sharing/sharing-service.ts effectiveSharingModel=private', proof: 'showcase-private-owd.dogfood.test.ts' }, + { id: 'owd-public-read', summary: 'OWD public_read (everyone reads, owner writes)', state: 'enforced', + enforcement: 'plugin-sharing/sharing-service.ts (read model + canEdit)', proof: 'showcase-public-read-owd.dogfood.test.ts' }, + { id: 'controlled-by-parent', summary: 'master-detail controlled_by_parent', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' }, + { id: 'multi-tenant', summary: 'organization isolation', state: 'enforced', + enforcement: 'plugin-org-scoping + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' }, + { id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced', + enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' }, + { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, + + // ── Enforced (unit-proven; e2e proof is a follow-on) ─────────────────── + { id: 'object-crud', summary: 'object CRUD permissions', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts checkObjectPermission (fail-closed 403)' }, + { id: 'fls', summary: 'field-level security (read mask + write deny)', state: 'enforced', + enforcement: 'plugin-security/field-masker.ts + detectForbiddenWrites' }, + { id: 'ownership-stamp', summary: 'owner_id auto-stamp on insert', state: 'enforced', + enforcement: 'plugin-security/security-plugin.ts (insert owner_id inject)' }, + { id: 'record-share', summary: 'manual record shares (sys_record_share)', state: 'enforced', + enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter/canEdit' }, + { id: 'sharing-rules', summary: 'criteria/owner sharing rules', state: 'enforced', + enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)' }, + { id: 'role-hierarchy', summary: 'role-hierarchy widening (role_and_subordinates)', state: 'enforced', + enforcement: 'plugin-sharing/role-graph.ts RoleGraphService (sys_role.parent)' }, + { id: 'rls-compiler-fail-closed', summary: 'uncompilable RLS predicate is surfaced/denied, not dropped', state: 'enforced', + enforcement: 'plugin-security/rls-compiler.ts isSupportedRlsExpression + warn' }, + { id: 'system-permissions', summary: 'systemPermissions / tab-app gating', state: 'enforced', + enforcement: 'rest/rest-server.ts filterAppForUser' }, + + // ── Experimental — declared, NOT enforced (ADR-0049/0056 D8) ─────────── + { id: 'compliance-configs', summary: 'GDPR/HIPAA/PCI configs', state: 'experimental', note: 'no runtime consumer; marked [EXPERIMENTAL] (D8)' }, + { id: 'field-encryption', summary: 'at-rest field encryption', state: 'experimental', note: 'no crypto provider reads the config; marked [EXPERIMENTAL] (D8)' }, + { id: 'data-masking', summary: 'role-based data masking', state: 'experimental', note: 'FLS is the enforced field-visibility path; marked [EXPERIMENTAL] (D8)' }, + { id: 'rls-config-global', summary: 'global RLSConfig / RLSAuditEvent', state: 'experimental', note: 'not read by the RLS path; marked [EXPERIMENTAL] (D8)' }, + { id: 'requireAuth-default-flip', summary: 'flip the global requireAuth default to secure-by-default', state: 'experimental', + note: 'D2 warn landed; the flip is release-gated. Pre-req: built-in guest_portal so public forms survive (share-links already read as SYSTEM after token validation, so they are safe).' }, + + // ── Removed — by ADR-0049 (roadmap M2) ───────────────────────────────── + { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops', state: 'removed', note: 'ADR-0049 → roadmap M2' }, + { id: 'flow-run-as', summary: 'flow runAs', state: 'removed', note: 'ADR-0049 → roadmap M2' }, +]; diff --git a/packages/dogfood/test/authz-conformance.test.ts b/packages/dogfood/test/authz-conformance.test.ts new file mode 100644 index 0000000000..b56c147dce --- /dev/null +++ b/packages/dogfood/test/authz-conformance.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 D10 — the conformance matrix is a CHECKED artifact. These assertions +// make "every authorization primitive is in exactly one honest state, and every +// claimed proof exists" a green CI gate. A new fail-open (enforced row with no +// site/proof) or a deleted proof file breaks the build. + +import { describe, it, expect } from 'vitest'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VALID = new Set(['enforced', 'experimental', 'removed']); + +describe('ADR-0056 D10 — authorization conformance matrix', () => { + it('has no duplicate primitive ids', () => { + const ids = AUTHZ_CONFORMANCE.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('every primitive is in exactly one honest state', () => { + for (const p of AUTHZ_CONFORMANCE) { + expect(VALID.has(p.state), `${p.id} has invalid state '${p.state}'`).toBe(true); + } + }); + + it('every ENFORCED primitive declares an enforcement site (no silent claims)', () => { + const missing = AUTHZ_CONFORMANCE.filter((p) => p.state === 'enforced' && !p.enforcement).map((p) => p.id); + expect(missing, `enforced primitives missing an enforcement site: ${missing.join(', ')}`).toEqual([]); + }); + + it('every experimental/removed primitive carries a note (honest rationale)', () => { + const missing = AUTHZ_CONFORMANCE.filter((p) => p.state !== 'enforced' && !p.note).map((p) => p.id); + expect(missing, `non-enforced primitives missing a note: ${missing.join(', ')}`).toEqual([]); + }); + + it('every referenced dogfood proof FILE EXISTS (the ratchet)', () => { + const broken: string[] = []; + for (const p of AUTHZ_CONFORMANCE as AuthzPrimitive[]) { + if (p.proof && !existsSync(join(HERE, p.proof))) broken.push(`${p.id} → ${p.proof}`); + } + expect(broken, `conformance proofs missing on disk: ${broken.join(', ')}`).toEqual([]); + }); + + it('the high-risk owner/derived OWD primitives each carry an end-to-end proof', () => { + const highRisk = ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile']; + for (const id of highRisk) { + const p = AUTHZ_CONFORMANCE.find((x) => x.id === id); + expect(p, `missing matrix entry: ${id}`).toBeTruthy(); + expect(p!.proof, `${id} must carry a dogfood proof`).toBeTruthy(); + } + }); +});