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
13 changes: 13 additions & 0 deletions .changeset/book-audience-permission-set.md
Original file line number Diff line number Diff line change
@@ -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).
136 changes: 136 additions & 0 deletions content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="info">
**Source:** `packages/spec/src/security/explain.zod.ts`
</Callout>

## 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 | |


---

1 change: 1 addition & 0 deletions content/docs/references/security/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description: Complete reference for all security protocol schemas
This section contains all protocol schemas for the security layer of ObjectStack.

<Cards>
<Card href="/docs/references/security/explain" title="Explain" description="Source: packages/spec/src/security/explain.zod.ts" />
<Card href="/docs/references/security/permission" title="Permission" description="Source: packages/spec/src/security/permission.zod.ts" />
<Card href="/docs/references/security/rls" title="Rls" description="Source: packages/spec/src/security/rls.zod.ts" />
<Card href="/docs/references/security/sharing" title="Sharing" description="Source: packages/spec/src/security/sharing.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/security/meta.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"title": "Security Protocol",
"pages": [
"explain",
"permission",
"rls",
"sharing",
Expand Down
23 changes: 20 additions & 3 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,6 +112,7 @@ const result = FieldPermission.parse(data);
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | 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<string, any>` | optional | Context variables for RLS evaluation |
| **adminScope** | `Object` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/book.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Type: `string`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **profile** | `string` | ✅ | |
| **permissionSet** | `string` | ✅ | |

---

Expand Down
7 changes: 5 additions & 2 deletions packages/spec/src/system/book.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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);
});
});
15 changes: 11 additions & 4 deletions packages/spec/src/system/book.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down