Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/adr/0056-permission-model-landing-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
76 changes: 76 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
@@ -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' },
];
55 changes: 55 additions & 0 deletions packages/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading