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
43 changes: 43 additions & 0 deletions .changeset/adr-0090-showcase-permission-zoo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
"@objectstack/example-showcase": minor
"@objectstack/plugin-sharing": patch
"@objectstack/plugin-security": patch
---

ADR-0090 permission-model zoo + docs alignment.

**Showcase (`@objectstack/example-showcase`)** now exercises the full Permission
Model v2 authoring surface and is guarded by a new runtime dogfood test
(`showcase-permission-zoo.dogfood.test.ts`): typed `definePosition`/
`definePermissionSet`/`defineSharingRule` factories; six flat positions (the
stale pre-D3 `parent` fields are gone); permission sets covering CRUD+FLS+RLS,
org-depth read/write asymmetry (`readScope: 'org'` / `writeScope: 'own'`),
View-All (auditor) and Modify-All (ops) bypasses, `systemPermissions`
(`setup.access`), the `isDefault` everyone-suggestion (incl. personal-data
grants on the `private`-OWD note object), a guest-safe set for the `guest`
anchor (D9), and a delegated-administration `adminScope` bounded to a seeded
`sys_business_unit` subtree (D12). Objects gain `externalSharingModel` dials
(D11). A committed `access-matrix.json` opts the showcase into the D6 snapshot
gate. Hierarchy depths (`own_and_reports`/`unit`/`unit_and_below`) are
deliberately NOT authored — they are enterprise (`hierarchy-security`) and the
open runtime fails closed; BU-shaped visibility is demonstrated via the
enforced `unit_and_subordinates` sharing-rule recipient instead.

**`@objectstack/spec`**: `defineStack` strict cross-reference validation no
longer rejects permission grants or seed datasets that target platform-provided
objects (`sys_`/`cloud_`/`ai_` prefixes) — a delegated-admin set carrying CRUD
on the RBAC link tables (ADR-0090 D12) and an app seeding the business-unit
tree are legitimate shapes; the typo net stays intact for the stack's own
objects. Stale pre-ADR-0090 vocabulary in zod docstrings (rls/territory/
sharing/tool/agent) is rewritten; the auto-generated references (including the
previously missing `security/explain.mdx`) are regenerated.

**Docs**: `protocol/objectql/security.mdx` rewritten to the v2 model (no
profiles, positions, canonical OWD four + D1 private default +
`externalSharingModel`, position-scoped RLS, enforced sharing recipients);
`isProfile` scrubbed from every authoring example; the dead
`/docs/references/identity/role` link fixed; implementation-status and
plugin READMEs aligned. Remaining rename misses are tracked in #2722
(RLSUserContext.role), #2723 (portal `profiles`), #2724 (sys_record_share
`role` enum).
2 changes: 1 addition & 1 deletion content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ await aiService.chatWithTools(messages, tools, {
actor: {
id: currentUser.id,
name: currentUser.displayName,
roles: currentUser.roles,
positions: currentUser.positions,
permissions: currentUser.permissions,
},
conversationId,
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ai/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ end-user's `ExecutionContext` so tool calls respect row-level security:
```typescript
const reply = await aiService.chatWithTools(messages, tools, {
toolExecutionContext: {
actor: { id: currentUser.id, name: currentUser.displayName, roles: currentUser.roles, permissions: currentUser.permissions },
actor: { id: currentUser.id, name: currentUser.displayName, positions: currentUser.positions, permissions: currentUser.permissions },
conversationId,
environmentId,
},
Expand Down
5 changes: 3 additions & 2 deletions content/docs/concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,12 @@ That's the job of the other layers.
// src/permissions/sales_rep.permission.ts
import { definePermissionSet } from '@objectstack/spec';

// A permission set (here used as a profile) keyed by object and field.
// A permission set keyed by object and field — the only capability
// container (ADR-0090: capability is the union of held sets; the old
// profile concept was removed).
export const SalesRepPermission = definePermissionSet({
name: 'sales_rep',
label: 'Sales Rep',
isProfile: true,
objects: {
customer: {
allowCreate: true,
Expand Down
4 changes: 2 additions & 2 deletions content/docs/concepts/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ This ensures ObjectStack apps can run on Node.js + PostgreSQL today, Python + SQ
| Layer | Responsibility | Example |
| :--- | :--- | :--- |
| **Protocol** | Defines capabilities | A row-level-security policy slot (`operation` + `using` clause) |
| **App** | Defines business logic | `{ operation: 'select', using: "role == 'admin'" }` |
| **App** | Defines business logic | `{ operation: 'select', using: "owner_id == current_user.id" }` |
| **Engine** | Enforces the logic | Compiles the `using` condition into a query filter |

<Callout type="info">
CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/concepts/architecture) policies, whose `using` clause is a CEL predicate over context variables such as `current_user.roles` and `current_user.id`.
CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/concepts/architecture) policies, whose `using` clause is a CEL predicate over context variables such as `current_user.positions` and `current_user.id`.
</Callout>

### Single Source of Truth
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ indexes: [
| `active` | `boolean` | Is object active (default: `true`) |
| `isSystem` | `boolean` | System object, protected from deletion (default: `false`) |
| `abstract` | `boolean` | Abstract base, cannot be instantiated (default: `false`) |
| `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056). Canonical: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). Legacy aliases: `'read'`=public_read, `'read_write'`/`'full'`=public_read_write |
| `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056/0090). Canonical four only: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). The legacy aliases (`'read'`, `'read_write'`, `'full'`) were removed from the enum (ADR-0090 D4) — authoring rejects them. Unset on a custom object resolves to `'private'` (ADR-0090 D1) |
| `keyPrefix` | `string` | Short prefix for record IDs (e.g. `'001'`) |
| `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) |

Expand Down
1 change: 0 additions & 1 deletion content/docs/getting-started/common-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,6 @@ import { definePermissionSet } from '@objectstack/spec';
export const HrRepPermission = definePermissionSet({
name: 'hr_rep',
label: 'HR Rep',
isProfile: true,
objects: {
employee: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
},
Expand Down
6 changes: 3 additions & 3 deletions content/docs/getting-started/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,10 @@ hotcrm/
│ ├── dashboards/ # Executive, Sales, Service dashboards
│ ├── data/ # Seed data for all objects
│ ├── flows/ # 5 automation flows: case escalation, approval, ...
│ ├── profiles/ # 5 security profiles: admin, sales rep, manager, ...
│ ├── security/ # 5 permission sets: admin, sales rep, manager, ...
│ ├── rag/ # 4 RAG pipelines: product info, competitive intel, ...
│ ├── reports/ # 7 reports with tabular, summary, matrix formats
│ └── sharing/ # Sharing rules, role hierarchy, org defaults
│ └── sharing/ # Sharing rules, business-unit hierarchy, org defaults
```

### Features Showcased
Expand All @@ -244,7 +244,7 @@ hotcrm/
| **State Machine** | Lead lifecycle: `new → contacted → qualified → converted` with `Lead.state.ts` |
| **AI Agents** | Sales Assistant, Service Agent, Revenue Intelligence, Email Campaign, Lead Enrichment |
| **RAG Pipelines** | Product info, competitive intel, sales knowledge, support knowledge |
| **Security Model** | 5 profiles (Admin, Manager, Rep, Agent, Marketing), sharing rules, role hierarchy |
| **Security Model** | 5 permission sets (Admin, Manager, Rep, Agent, Marketing), sharing rules, business-unit hierarchy |
| **Automation** | Lead conversion, case escalation, opportunity approval, campaign enrollment, quote generation |
| **Custom APIs** | `POST /lead-convert`, `GET /pipeline-stats` |
| **Seed Data** | 50+ realistic records across all objects |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/glossary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ External communication layer including REST contracts, API discovery, Realtime s
Business process automation including Workflows (state machines), Flows (visual logic), and Webhooks (HTTP callbacks).

### Identity Protocol
User, organization, and profile schemas for identity management.
User, organization, and position schemas for identity management.

### Security Protocol
RBAC, permissions, policy, and access-control schemas.
Expand Down
4 changes: 2 additions & 2 deletions content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,13 @@ Access control, permissions, and row-level security.

## Identity Protocol (4 schemas)

User identity, organizations, and role management.
User identity, organizations, and position management.

| Protocol | Source File | Key Schemas | Purpose |
|:---------|:-----------|:------------|:--------|
| **[Identity](/docs/references/identity/identity)** | `identity.zod.ts` | Identity, User | User identity management |
| **[Organization](/docs/references/identity/organization)** | `organization.zod.ts` | Organization | Multi-organization support |
| **[Role](/docs/references/identity/role)** | `role.zod.ts` | Role | Role-based access control |
| **[Position](/docs/references/identity/position)** | `position.zod.ts` | Position | Permission-set distribution (岗位, ADR-0090) |
| **[SCIM](/docs/references/identity/scim)** | `scim.zod.ts` | SCIMUser, SCIMGroup | SCIM 2.0 provisioning |

## Cloud Protocol (4 schemas)
Expand Down
2 changes: 1 addition & 1 deletion content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Each module documents one capability in depth — overview first, then guides, w
<Cards>
<Card href="/docs/data-modeling" title="Data Modeling" description="Objects, fields, relationships, validation, formulas, and queries (ObjectQL)" />
<Card href="/docs/automation" title="Automation" description="Hooks, flows, workflows, approvals, and webhooks — the process engine" />
<Card href="/docs/permissions" title="Permissions & Identity" description="Authentication, roles, sharing rules, and field-level security" />
<Card href="/docs/permissions" title="Permissions & Identity" description="Authentication, permission sets, positions, sharing rules, and field-level security" />
<Card href="/docs/ui" title="UI Engine" description="Apps, views, dashboards, and forms as server-driven UI (ObjectUI)" />
<Card href="/docs/api" title="API & SDK" description="Generated REST, realtime, and client SDK surfaces" />
<Card href="/docs/ai" title="AI" description="Agents, actions as tools, RAG, and natural-language queries" />
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/contracts/auth-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Resolves the authenticated user from a request. Returns `undefined` when the req
```typescript
const user = await authService.getCurrentUser?.(request);
if (user) {
console.log(`Request from ${user.name} (${user.roles?.join(', ')})`);
console.log(`Request from ${user.name} (${user.positions?.join(', ')})`);
}
```

Expand Down
40 changes: 21 additions & 19 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ const accounts = await dataEngine.find('account');
// owner_id == current_user.id (CEL) → compiled to a row filter by the RLS compiler
```

The model is composed of three distinct metadata types (see `packages/spec/src/security` and `packages/spec/src/identity`):
The model is composed of three distinct metadata types (see `packages/spec/src/security` and `packages/spec/src/identity`), per the [Permission Model v2 vocabulary](/docs/permissions) (ADR-0090):

- **Permission Set** (`permission` / `profile` metadata) — the CRUD/FLS grant. A *profile* is a permission set with `isProfile: true`.
- **Role** (`role` metadata) — the reporting hierarchy used for ownership-based visibility roll-up.
- **Permission Set** (`permission` metadata) — the only capability container: CRUD, FLS, RLS, scope depth, View/Modify-All, system permissions. There is no profile concept — a user's capability is the *union* of every set they hold.
- **Position** (`position` metadata) — the flat distribution layer: who holds which permission sets. Hierarchy lives in the **business-unit tree** (`sys_business_unit`), never on positions.
- **Sharing Rule** + **Organization-Wide Defaults (OWD)** — broaden the baseline visibility set for an object.

---
Expand Down Expand Up @@ -81,7 +81,6 @@ Object permissions live inside a **permission set**. Each entry in the `objects`
# sales_rep.permission.yml
name: sales_rep # lowercase snake_case, required
label: Sales Representative
isProfile: false # set true to make this a base profile
objects:
account:
allowCreate: true
Expand All @@ -106,7 +105,7 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
| `viewAllRecords` | Read every record, bypassing sharing & ownership |
| `modifyAllRecords` | Write every record, bypassing sharing & ownership |

> Permission sets are layered: a user has exactly one **profile** (`isProfile: true`) plus zero or more add-on permission sets. Grants are additive (a `true` anywhere wins).
> Permission sets are **additive-only**: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in `everyone` baseline (ADR-0090 D5). A `true` anywhere wins; there are no subtraction sets — to withhold, don't grant.

### Permission Check Flow

Expand All @@ -129,7 +128,7 @@ Return filtered result

## 2. Row-Level Security

Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `id in current_user.roles`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `'manager' in current_user.positions`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.

Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.

Expand Down Expand Up @@ -162,19 +161,19 @@ rowLevelSecurity:
check: 'organization_id == current_user.organization_id'
```

The `RLS` helper factory exports `RLS.tenantPolicy(object)` / `RLS.ownerPolicy(object)` / `RLS.rolePolicy(...)` to generate these.
The `RLS` helper factory exports `RLS.tenantPolicy(object)` / `RLS.ownerPolicy(object)` / `RLS.positionPolicy(...)` to generate these.

### Role-Scoped Access
### Position-Scoped Access

`roles` restricts a policy to specific roles; omit it to apply to everyone (except `bypassRoles`):
`positions` restricts a policy to users holding one of the named positions (ADR-0090 D3; formerly `roles`); omit it to apply to everyone:

```yaml
rowLevelSecurity:
- name: manager_team_access
object: task
operation: select
using: 'assigned_to_id in current_user.team_member_ids' # pre-resolved §7.3.1 set — NOT a subquery (ADR-0055)
roles: [manager, director]
positions: [manager, director]
```

### Regional / Territory Access
Expand All @@ -185,7 +184,7 @@ rowLevelSecurity:
object: account
operation: select
using: 'territory_id in current_user.territory_ids' # pre-resolved set (current_user.region is not exposed)
roles: [sales_rep]
positions: [sales_rep]
```

### Time-Based Access
Expand Down Expand Up @@ -264,12 +263,12 @@ fields:

## 5. Sharing Rules & Organization-Wide Defaults

The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of `private`, `read`, `read_write`, `full`. **Sharing rules** then grant *additional* access on top of that baseline.
The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of the four canonical values `private`, `public_read`, `public_read_write`, `controlled_by_parent` (ADR-0090 D4 — the legacy aliases `read`/`read_write`/`full` were **removed from the enum**; authoring rejects them). A custom object with no declared `sharingModel` resolves to **`private`** (ADR-0090 D1 — the unset state no longer means public). An optional `externalSharingModel` (same enum, default `private`, never wider than the internal value) is the stricter dial for external portal/partner principals (ADR-0090 D11). **Sharing rules** then grant *additional* access on top of that baseline — sharing only ever widens, RLS only ever narrows.

```yaml
# account.object.yml
name: account
sharingModel: private # owner-only by default
sharingModel: private # owner-only baseline (also the D1 default)
```

### Criteria-Based Sharing
Expand All @@ -284,27 +283,31 @@ object: account
accessLevel: read # read | edit | full
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: role # user | group | role | role_and_subordinates | guest
type: position # user | group | position | unit_and_subordinates | guest
value: sales_rep
```

`unit_and_subordinates` expands a **business-unit subtree**: the unit named by `value` plus every descendant unit's members (ADR-0057 D5 / ADR-0090 D3 — the former position-tree walk was re-homed onto the `sys_business_unit` tree).

### Owner-Based Sharing

Share records owned by one group/role with another (`OwnerSharingRuleSchema`):
Share records owned by one group with another (`OwnerSharingRuleSchema`):

```yaml
name: share_west_region
type: owner
object: account
accessLevel: edit
ownedBy:
type: role
type: position
value: west_region_reps
sharedWith:
type: role
type: position
value: west_region_managers
```

> **Enforcement status.** Criteria rules with `user` / `position` / `unit_and_subordinates` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3). Owner-type rules and `group`/`guest` recipients are `[experimental — not enforced]`: the seed bootstrap skips them (logged) rather than seeding a permissive match-all (ADR-0049).

> `accessLevel` is one of `read`, `edit`, or `full`. `full` additionally grants transfer/share/delete.

### Public Share Links
Expand Down Expand Up @@ -384,9 +387,8 @@ fields:
### Principle of Least Privilege

```yaml
# Restrictive profile: read only what you own
# Restrictive permission set: read only what you own
name: account_owner
isProfile: true
objects:
account:
allowRead: true
Expand Down
Loading