From b723b7b54a2e0481d5f8e5be4aee22a0fdf2b9c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 03:32:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20book=20audience=20gates=20by=20p?= =?UTF-8?q?ermission=20set=20=E2=80=94=20retire=20the=20{=20profile=20}=20?= =?UTF-8?q?arm=20(ADR-0090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0090 D2 removed the Profile concept, but BookAudienceSchema still modelled its gated arm as { profile: string } (with pre-D3 vocabulary in the comments). Books ship in packages, and packages own permission sets but never positions (D9 — the same environment-ownership argument that removed profiles), so the gate is a capability reference: - `{ profile: string }` → `{ permissionSet: string }` — one-step rename, no alias; the zod union now rejects `{ profile }` at parse time. - `'public'` comment now points at the built-in `guest` position (D9) instead of the removed "guest profile". - Regenerated content/docs/references (book.mdx), which also catches up security/* with the AdminScope (D12) and explain (D6) shapes that landed without a docs regen. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ph4jEAfWDh6taMbZweWhom --- .changeset/book-audience-permission-set.md | 13 ++ content/docs/references/security/explain.mdx | 136 ++++++++++++++++++ content/docs/references/security/index.mdx | 1 + content/docs/references/security/meta.json | 1 + .../docs/references/security/permission.mdx | 23 ++- content/docs/references/system/book.mdx | 2 +- packages/spec/src/system/book.test.ts | 7 +- packages/spec/src/system/book.zod.ts | 15 +- 8 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 .changeset/book-audience-permission-set.md create mode 100644 content/docs/references/security/explain.mdx diff --git a/.changeset/book-audience-permission-set.md b/.changeset/book-audience-permission-set.md new file mode 100644 index 0000000000..d0c3a901bd --- /dev/null +++ b/.changeset/book-audience-permission-set.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': major +--- + +`BookAudience` gated arm renamed: `{ profile: string }` → `{ permissionSet: string }`. + +ADR-0090 D2 removed the Profile concept, but `book.audience` (ADR-0046 §6.7) +still modelled its gated arm as a profile reference. Books ship in packages, +and packages own permission sets but never positions (ADR-0090 D9), so the +gate is a capability reference — a permission-set name the reader must hold, +e.g. `{ permissionSet: 'crm_admin' }`. Pre-launch one-step rename, no alias: +the zod union now rejects `{ profile }` at parse time. `'org'` and `'public'` +literals are unchanged (`'public'` ≡ the built-in `guest` position, D9). diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx new file mode 100644 index 0000000000..966c550f19 --- /dev/null +++ b/content/docs/references/security/explain.mdx @@ -0,0 +1,136 @@ +--- +title: Explain +description: Explain protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +[ADR-0090 D6] Access-explanation contract — `explain(principal, object, + +operation)` as a first-class API. + +The explain engine (`@objectstack/plugin-security`) walks the SAME code + +paths as the enforcement middleware — the same permission-set resolution, + +the same evaluator, the same RLS compiler — and reports what each layer of + +the evaluation pipeline contributed to the final decision. "Explained by + +construction": the report can never drift from enforcement because it IS + +enforcement, minus the throw. + +Layer order mirrors the runtime pipeline: + +principal → required_permissions → object_crud → fls → owd_baseline → + +depth → sharing → vama_bypass → rls. + + +**Source:** `packages/spec/src/security/explain.zod.ts` + + +## TypeScript Usage + +```typescript +import { AccessMatrix, AccessMatrixEntry, ExplainDecision, ExplainLayer, ExplainOperation, ExplainRequest } from '@objectstack/spec/security'; +import type { AccessMatrix, AccessMatrixEntry, ExplainDecision, ExplainLayer, ExplainOperation, ExplainRequest } from '@objectstack/spec/security'; + +// Validate data +const result = AccessMatrix.parse(data); +``` + +--- + +## AccessMatrix + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **version** | `number` | ✅ | | +| **entries** | `Object[]` | ✅ | | + + +--- + +## AccessMatrixEntry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **permissionSet** | `string` | ✅ | | +| **object** | `string` | ✅ | | +| **create** | `boolean` | ✅ | | +| **read** | `boolean` | ✅ | | +| **edit** | `boolean` | ✅ | | +| **delete** | `boolean` | ✅ | | +| **viewAllRecords** | `boolean` | ✅ | | +| **modifyAllRecords** | `boolean` | ✅ | | +| **readScope** | `string` | optional | | +| **writeScope** | `string` | optional | | +| **sharingModel** | `string` | optional | | + + +--- + +## ExplainDecision + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **allowed** | `boolean` | ✅ | | +| **object** | `string` | ✅ | | +| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | | +| **principal** | `Object` | ✅ | | +| **layers** | `Object[]` | ✅ | | +| **readFilter** | `any` | optional | | + + +--- + +## ExplainLayer + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **layer** | `Enum<'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>` | ✅ | | +| **verdict** | `Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>` | ✅ | | +| **detail** | `string` | ✅ | | +| **contributors** | `Object[]` | ✅ | | + + +--- + +## ExplainOperation + +### Allowed Values + +* `read` +* `create` +* `update` +* `delete` +* `transfer` +* `restore` +* `purge` + + +--- + +## ExplainRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | | +| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | | +| **userId** | `string` | optional | | + + +--- + diff --git a/content/docs/references/security/index.mdx b/content/docs/references/security/index.mdx index 4b94ae0a29..2885a067a9 100644 --- a/content/docs/references/security/index.mdx +++ b/content/docs/references/security/index.mdx @@ -6,6 +6,7 @@ description: Complete reference for all security protocol schemas This section contains all protocol schemas for the security layer of ObjectStack. + diff --git a/content/docs/references/security/meta.json b/content/docs/references/security/meta.json index 1e70554bfd..9d9dcff0cd 100644 --- a/content/docs/references/security/meta.json +++ b/content/docs/references/security/meta.json @@ -1,6 +1,7 @@ { "title": "Security Protocol", "pages": [ + "explain", "permission", "rls", "sharing", diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 92e2af7597..4d1063964f 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -24,13 +24,29 @@ Refined with enterprise data lifecycle controls: ## TypeScript Usage ```typescript -import { FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security'; -import type { FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security'; +import { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security'; +import type { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security'; // Validate data -const result = FieldPermission.parse(data); +const result = AdminScope.parse(data); ``` +--- + +## AdminScope + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root | +| **includeSubtree** | `boolean` | ✅ | Cover descendant business units too (default true) | +| **manageAssignments** | `boolean` | ✅ | Manage user↔position assignments within the subtree | +| **manageBindings** | `boolean` | ✅ | Manage position↔permission-set bindings within the subtree | +| **authorEnvironmentSets** | `boolean` | ✅ | Author environment-owned permission sets | +| **assignablePermissionSets** | `string[]` | ✅ | Allowlist of permission-set names the delegate may hand out | + + --- ## FieldPermission @@ -96,6 +112,7 @@ const result = FieldPermission.parse(data); | **tabPermissions** | `Record>` | optional | App/tab visibility: visible, hidden, default_on (shown by default), default_off (available but hidden initially) | | **rowLevelSecurity** | `Object[]` | optional | Row-level security policies (see rls.zod.ts for full spec) | | **contextVariables** | `Record` | optional | Context variables for RLS evaluation | +| **adminScope** | `Object` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) | --- diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index c4c9f321b8..d9169446f0 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -94,7 +94,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **profile** | `string` | ✅ | | +| **permissionSet** | `string` | ✅ | | --- diff --git a/packages/spec/src/system/book.test.ts b/packages/spec/src/system/book.test.ts index 3d96fdb032..60d47faced 100644 --- a/packages/spec/src/system/book.test.ts +++ b/packages/spec/src/system/book.test.ts @@ -25,10 +25,13 @@ describe('BookSchema (ADR-0046 §6)', () => { expect(() => BookSchema.parse({ name: 'crm_guide', groups: [{ key: 'Start', label: 'x' }] })).toThrow(); }); it('accepts audience variants', () => { - for (const audience of ['org', 'public', { profile: 'admin' }] as const) { + for (const audience of ['org', 'public', { permissionSet: 'crm_admin' }] as const) { expect(() => BookSchema.parse({ name: 'b', audience, groups: [] })).not.toThrow(); } }); + it('rejects the removed { profile } audience shape (ADR-0090 D2)', () => { + expect(() => BookSchema.parse({ name: 'b', audience: { profile: 'admin' }, groups: [] })).toThrow(); + }); }); describe('resolveBookTree — derived membership (the AI-safety core)', () => { @@ -151,7 +154,7 @@ describe('deriveImplicitPackageBook + audience', () => { it('isPublicAudience only true for public', () => { expect(isPublicAudience('public')).toBe(true); expect(isPublicAudience('org')).toBe(false); - expect(isPublicAudience({ profile: 'admin' })).toBe(false); + expect(isPublicAudience({ permissionSet: 'crm_admin' })).toBe(false); expect(isPublicAudience(undefined)).toBe(false); }); }); diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 046ad71c52..8125a5fb85 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -82,15 +82,22 @@ export type BookGroup = { pages?: BookNode[]; }; -/** Access audience for a book — a reference into the permission model (ADR-0046 §6.7). */ +/** + * Access audience for a book — a reference into the permission model + * (ADR-0046 §6.7, vocabulary per ADR-0090). The gate is a capability + * reference (a permission-set name), never a distribution one: books ship in + * packages, and packages own permission sets but never positions (ADR-0090 + * D9) — a package gating its Admin Guide to its own `crm_admin` set keeps + * provenance and uninstall semantics intact (ADR-0086). + */ export const BookAudienceSchema = lazySchema(() => z.union([ z.literal('org'), // default — inherits the package grant (§3.6) - z.literal('public'), // ≡ the data-layer `guest` profile (anonymous, indexable) - z.object({ profile: z.string() }), // role-gated, e.g. { profile: 'admin' } + z.literal('public'), // ≡ the built-in `guest` position (ADR-0090 D9): anonymous, indexable + z.object({ permissionSet: z.string() }), // capability-gated, e.g. { permissionSet: 'crm_admin' } ]), ); -export type BookAudience = 'org' | 'public' | { profile: string }; +export type BookAudience = 'org' | 'public' | { permissionSet: string }; export const BookSchema = lazySchema(() => z.object({