From 2e7b96c35fbdeedb53e1e7c2c8c659ada5ab6132 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 11:50:32 +0800 Subject: [PATCH] feat(showcase,docs): ADR-0090 permission-model zoo + full docs alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Showcase now exercises the complete Permission Model v2 authoring surface (positions via typed factories, CRUD+FLS+RLS, org-depth read/write asymmetry, VAMA, systemPermissions, the isDefault everyone-suggestion, guest-safe capability, adminScope delegated administration anchored on a seeded sys_business_unit tree, externalSharingModel dials) and opts into the D6 access-matrix snapshot gate; guarded end-to-end by a new dogfood test. defineStack cross-reference validation learns that platform-provided (sys_/cloud_/ai_) objects are legitimate permission-grant and seed targets (ADR-0090 D12 delegated-admin shape; seed-loader parity), with tests. Docs: protocol/objectql/security.mdx rewritten to the v2 vocabulary; isProfile scrubbed from all authoring examples; dead identity/role reference link fixed; stale zod docstrings (rls/territory/sharing/tool/agent) rewritten and the security/ai references regenerated (adds the missing explain.mdx page). Deliberately NOT demoed (declared ≠ enforced): hierarchy depth scopes (enterprise hierarchy-security), tabPermissions (single-app package, ADR-0019 D3), owner-type sharing rules (seed-skipped [experimental]). Rename misses and platform bugs found while verifying are tracked in #2722 #2723 #2724 (vocabulary leftovers) and #2734 #2735 #2737 (fresh-boot org-RLS visibility, JSON-field seed batch inserts, count-scope leak). Co-Authored-By: Claude Fable 5 --- .../adr-0090-showcase-permission-zoo.md | 43 +++ content/docs/ai/actions-as-tools.mdx | 2 +- content/docs/ai/index.mdx | 2 +- content/docs/concepts/architecture.mdx | 5 +- content/docs/concepts/index.mdx | 4 +- content/docs/data-modeling/objects.mdx | 2 +- .../docs/getting-started/common-patterns.mdx | 1 - content/docs/getting-started/examples.mdx | 6 +- content/docs/getting-started/glossary.mdx | 2 +- .../docs/getting-started/quick-reference.mdx | 4 +- content/docs/index.mdx | 2 +- .../docs/kernel/contracts/auth-service.mdx | 2 +- content/docs/protocol/objectql/security.mdx | 40 +- content/docs/references/ai/agent.mdx | 2 +- content/docs/references/ai/tool.mdx | 2 +- 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/security/rls.mdx | 14 +- content/docs/references/security/sharing.mdx | 2 +- .../docs/references/security/territory.mdx | 10 +- .../docs/releases/implementation-status.mdx | 10 +- content/docs/ui/forms.mdx | 21 +- content/docs/ui/index.mdx | 2 +- content/docs/ui/role-based-interfaces.mdx | 12 +- .../app-crm/src/security/sales-positions.ts | 3 +- examples/app-showcase/access-matrix.json | 347 ++++++++++++++++++ examples/app-showcase/src/coverage.ts | 14 +- .../src/data/objects/account.object.ts | 4 + .../src/data/objects/announcement.object.ts | 11 +- examples/app-showcase/src/data/seed/index.ts | 35 +- .../src/docs/showcase_tour_security.md | 102 ++++- examples/app-showcase/src/security/index.ts | 219 +++-------- .../src/security/permission-sets.ts | 288 +++++++++++++++ .../app-showcase/src/security/positions.ts | 74 ++++ .../src/security/sharing-rules.ts | 101 +++++ examples/app-showcase/test/seed.test.ts | 5 +- ...howcase-d7-default-profile.dogfood.test.ts | 10 +- .../showcase-permission-zoo.dogfood.test.ts | 214 +++++++++++ packages/plugins/plugin-security/README.md | 12 +- .../src/objects/sys-sharing-rule.object.ts | 2 +- packages/spec/src/ai/agent.zod.ts | 4 +- packages/spec/src/ai/tool.zod.ts | 4 +- packages/spec/src/security/rls.zod.ts | 21 +- packages/spec/src/security/sharing.zod.ts | 4 +- packages/spec/src/security/territory.zod.ts | 13 +- packages/spec/src/stack.test.ts | 55 +++ packages/spec/src/stack.zod.ts | 26 +- 49 files changed, 1613 insertions(+), 306 deletions(-) create mode 100644 .changeset/adr-0090-showcase-permission-zoo.md create mode 100644 content/docs/references/security/explain.mdx create mode 100644 examples/app-showcase/access-matrix.json create mode 100644 examples/app-showcase/src/security/permission-sets.ts create mode 100644 examples/app-showcase/src/security/positions.ts create mode 100644 examples/app-showcase/src/security/sharing-rules.ts create mode 100644 packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts diff --git a/.changeset/adr-0090-showcase-permission-zoo.md b/.changeset/adr-0090-showcase-permission-zoo.md new file mode 100644 index 0000000000..b4e81c4c34 --- /dev/null +++ b/.changeset/adr-0090-showcase-permission-zoo.md @@ -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). diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index 78f13745cb..e063218645 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -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, diff --git a/content/docs/ai/index.mdx b/content/docs/ai/index.mdx index 08337814f5..da25323785 100644 --- a/content/docs/ai/index.mdx +++ b/content/docs/ai/index.mdx @@ -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, }, diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx index 96f76184cf..e499789a63 100644 --- a/content/docs/concepts/architecture.mdx +++ b/content/docs/concepts/architecture.mdx @@ -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, diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx index 619569e1e6..a69302c4da 100644 --- a/content/docs/concepts/index.mdx +++ b/content/docs/concepts/index.mdx @@ -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 | -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`. ### Single Source of Truth diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 82f84db261..719247d9c3 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -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)) | diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx index 5780732a93..005ca08bac 100644 --- a/content/docs/getting-started/common-patterns.mdx +++ b/content/docs/getting-started/common-patterns.mdx @@ -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 }, }, diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index dd6aa0e89a..7413536ead 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -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 @@ -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 | diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx index 29aaf1e7a1..18477b22cf 100644 --- a/content/docs/getting-started/glossary.mdx +++ b/content/docs/getting-started/glossary.mdx @@ -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. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 4f4482a463..c6649c0864 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -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) diff --git a/content/docs/index.mdx b/content/docs/index.mdx index 013a8f6b64..0403135bf0 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -40,7 +40,7 @@ Each module documents one capability in depth — overview first, then guides, w - + diff --git a/content/docs/kernel/contracts/auth-service.mdx b/content/docs/kernel/contracts/auth-service.mdx index 26c2e2c67b..a658c322a8 100644 --- a/content/docs/kernel/contracts/auth-service.mdx +++ b/content/docs/kernel/contracts/auth-service.mdx @@ -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(', ')})`); } ``` diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index f288271183..64e3f077f2 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -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. --- @@ -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 @@ -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 @@ -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. @@ -162,11 +161,11 @@ 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: @@ -174,7 +173,7 @@ rowLevelSecurity: 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 @@ -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 @@ -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 @@ -284,13 +283,15 @@ 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 @@ -298,13 +299,15 @@ 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 @@ -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 diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index efb111c10e..b0409894f8 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -82,7 +82,7 @@ const result = AIKnowledge.parse(data); | **knowledge** | `Object` | optional | RAG access | | **active** | `boolean` | ✅ | | | **access** | `string[]` | optional | Who can chat with this agent | -| **permissions** | `string[]` | optional | Required permissions or roles | +| **permissions** | `string[]` | optional | Required permission-set capabilities | | **tenantId** | `string` | optional | Tenant/Organization ID | | **visibility** | `Enum<'global' \| 'organization' \| 'private'>` | ✅ | [EXPERIMENTAL — NOT ENFORCED, #1901] Intended listing scope. No runtime consumer yet; use access/permissions for real gating. | | **planning** | `Object` | optional | Autonomous reasoning and planning configuration | diff --git a/content/docs/references/ai/tool.mdx b/content/docs/references/ai/tool.mdx index a3451dd650..be5081bd73 100644 --- a/content/docs/references/ai/tool.mdx +++ b/content/docs/references/ai/tool.mdx @@ -39,7 +39,7 @@ const result = Tool.parse(data); | **outputSchema** | `Record` | optional | JSON Schema for tool output | | **objectName** | `string` | optional | Target object name (snake_case) | | **requiresConfirmation** | `boolean` | ✅ | Require user confirmation before execution | -| **permissions** | `string[]` | optional | Required permissions or roles | +| **permissions** | `string[]` | optional | Required permission-set capabilities | | **active** | `boolean` | ✅ | Whether the tool is enabled | | **builtIn** | `boolean` | ✅ | Platform built-in tool flag | | **protection** | `Object` | optional | Package author protection block — lock policy for this tool. | 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/security/rls.mdx b/content/docs/references/security/rls.mdx index 5e8507889a..26f5e8a34e 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -15,9 +15,9 @@ and Salesforce Criteria-Based Sharing Rules. Row-Level Security (RLS) allows you to control which rows users can access -in database tables based on their identity and role. Unlike object-level +in database tables based on their identity and positions. Unlike -permissions (CRUD), RLS provides record-level filtering. +object-level permissions (CRUD), RLS provides record-level filtering. ## Use Cases @@ -97,15 +97,17 @@ using: 'organization_id == current_user.organization_id' ## Salesforce Sharing Rules Comparison -Salesforce uses "Sharing Rules" and "Role Hierarchy" for record-level access. +Salesforce uses "Sharing Rules" and a visibility hierarchy for record-level + +access (our equivalent hierarchy is the business-unit tree, ADR-0090 D3). ObjectStack RLS provides similar functionality with more flexibility. Salesforce: -- Criteria-Based Sharing: Share records matching criteria with users/roles +- Criteria-Based Sharing: Share records matching criteria with users/groups -- Owner-Based Sharing: Share records based on owner's role +- Owner-Based Sharing: Share records based on who owns them - Manual Sharing: Individual record sharing @@ -123,7 +125,7 @@ ObjectStack RLS: 2. **Define INSERT/UPDATE CHECK Policies**: Prevent data leakage -3. **Use Role-Based Policies**: Apply different rules to different roles +3. **Use Position-Scoped Policies**: Apply different rules to different positions 4. **Test Thoroughly**: RLS can have complex interactions diff --git a/content/docs/references/security/sharing.mdx b/content/docs/references/security/sharing.mdx index 3ad113f440..ffb5e253d8 100644 --- a/content/docs/references/security/sharing.mdx +++ b/content/docs/references/security/sharing.mdx @@ -51,7 +51,7 @@ const result = OWDModel.parse(data); | **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | ✅ | | | **sharedWith** | `Object` | ✅ | The recipient of the shared access | | **type** | `string` | ✅ | | -| **ownedBy** | `Object` | ✅ | Source group/role whose records are being shared | +| **ownedBy** | `Object` | ✅ | Source group/position whose records are being shared | --- diff --git a/content/docs/references/security/territory.mdx b/content/docs/references/security/territory.mdx index e32d75ea37..081b4adc16 100644 --- a/content/docs/references/security/territory.mdx +++ b/content/docs/references/security/territory.mdx @@ -7,7 +7,9 @@ description: Territory protocol schemas Territory Management Protocol -Defines a matrix reporting structure that exists parallel to the Role Hierarchy. +Defines a matrix reporting structure that exists parallel to the + +business-unit hierarchy (ADR-0090 D3 — the org tree is `sys_business_unit`). USE CASE: @@ -17,15 +19,15 @@ USE CASE: - Strategic Accounts (Account-based: "Strategic Accounts") -DIFFERENCE FROM ROLE: +DIFFERENCE FROM THE BUSINESS-UNIT TREE: -- Role: Hierarchy of PEOPLE (Who reports to whom). Stable. HR-driven. +- Business unit: Hierarchy of PEOPLE (org structure). Stable. HR-driven. - Territory: Hierarchy of ACCOUNTS/REVENUE (Who owns which market). Flexible. Sales-driven. - One User can be assigned to MANY Territories (Matrix). -- One User has only ONE Role (Tree). +- One User belongs to one primary business unit (Tree). **Source:** `packages/spec/src/security/territory.zod.ts` diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 119eefcbd4..522feefb42 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -287,8 +287,8 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity - Client SDK supports bearer token header — but token validation requires the auth plugin - Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered - Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin -- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A). -- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` now accepts the canonical OWD vocabulary (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) alongside the legacy `read` / `read_write` / `full` spellings (D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `roles` (#2054). Permission sets may declare `isDefault: true` to act as the app-declared fallback profile (D7). +- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A). +- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism). --- @@ -426,9 +426,9 @@ cloud / EE. - [x] Analytics RLS bridge — `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` and fails closed when read-scope resolution cannot be safely applied - [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records across `sys_organization`, `sys_member`, `sys_user`, and `sys_*_permission_set` link tables) - [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority -- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). `object.sharingModel` accepts the canonical OWD vocabulary alongside the legacy `read` / `read_write` / `full` spellings (D1) -- [x] Sharing Rule evaluator — owner + criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); recipients include user / role / group and configurable `role_and_subordinates` role-hierarchy widening (ADR-0056 D6) -- [x] App-declarable default profile — a permission set may set `isDefault: true` to be the fallback profile for unassigned users (ADR-0056 D7) +- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). Canonical vocabulary only — legacy aliases removed from the enum (ADR-0090 D4); unset custom-object OWD resolves to `private` (ADR-0090 D1) +- [x] Sharing Rule evaluator — criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); enforced recipients are user / position / `unit_and_subordinates` (business-unit-subtree widening, ADR-0057 D5 / ADR-0090 D3); owner-type rules and group/guest recipients remain `[experimental — not enforced]` +- [x] Everyone-baseline suggestion — a permission set may set `isDefault: true` as the install-time suggestion to bind it to the built-in `everyone` position; resolved per-request as an additive baseline, no fallback cliff (ADR-0090 D5) - [x] Default-deny for anonymous traffic — the global default-deny **flip landed** (ADR-0056 D2): `requireAuth` defaults to `true`, explicit `requireAuth: false` opt-outs warn at boot, and public forms self-authorize via `publicFormGrant` (Option A) - [ ] Studio RLS visual editor - [ ] Per-user×org permission cache diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 32b9b099b2..f73991a288 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -45,7 +45,7 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o > and the SecurityPlugin authorizes **only** create + the immediate read-back on > exactly that object, never anything else and never the anonymous fall-open. So > public forms work under secure-by-default (`requireAuth: true`) with **no** -> `guest_portal` profile. The `guest_portal` permission set + `anonymous` flag +> `guest_portal` permission set. The `guest_portal` permission set + `anonymous` flag > are still attached for **back-compat** (object hooks that detect a guest via a > falsy `ctx.user?.id`), but they are no longer the authorization mechanism. @@ -97,23 +97,24 @@ export default defineView({ > - Anything not in the `sections[].fields[]` whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list. > - Multiple form views per object are fine — only the one(s) with `sharing.allowAnonymous === true` are exposed. -## 2. (Optional) Create the `guest_portal` profile +## 2. (Optional) Create the `guest_portal` permission set -Authorization no longer depends on this profile — the declaration-derived +Authorization no longer depends on this set — the declaration-derived `publicFormGrant` (see the note above) is what permits the insert. You only need -a `guest_portal` profile if you rely on the legacy back-compat path (e.g. an -older runtime, or object hooks that branch on the `guest_portal` permission). +a `guest_portal` permission set if you rely on the legacy back-compat path (e.g. +an older runtime, or object hooks that branch on the `guest_portal` permission). When present it is still attached to the anonymous context, so keep it -INSERT-only on the target object. +INSERT-only on the target object — the guest-safe shape (ADR-0090 D9). ```ts -// hotcrm/src/profiles/guest-portal.profile.ts -import { PermissionSetSchema } from '@objectstack/spec/security'; +// hotcrm/src/security/guest-portal.permission.ts +import { definePermissionSet } from '@objectstack/spec/security'; -export default PermissionSetSchema.parse({ +// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no +// longer exists — the Profile concept was removed by ADR-0090 D2.) +export default definePermissionSet({ name: 'guest_portal', label: 'Public Form Submitters', - isProfile: true, objects: { lead: { allowCreate: true }, // no read/edit/delete case: { allowCreate: true }, diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index 6e9035c597..7a44f48b6e 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -52,7 +52,7 @@ export const CrmApp = App.create({ - + diff --git a/content/docs/ui/role-based-interfaces.mdx b/content/docs/ui/role-based-interfaces.mdx index d55f020f8b..66b24a34cd 100644 --- a/content/docs/ui/role-based-interfaces.mdx +++ b/content/docs/ui/role-based-interfaces.mdx @@ -1,9 +1,9 @@ --- -title: Role-based interfaces +title: Audience-based interfaces description: The same data serves different audiences. Give end users a curated app/page and keep builder surfaces — Studio, raw object tables, automation config — out of their view. Separate consumer and builder paths by default. --- -# Role-based interfaces +# Audience-based interfaces ## Scenario @@ -18,7 +18,7 @@ description: The same data serves different audiences. Give end users a curated | **End user (consumer)** | a curated **App** → `page` / `view` (interface mode) | `App.requiredPermissions`, permission-set `tabPermissions`, nav-item gating | | **Builder / admin** | **Setup / Studio**, raw object tables (data mode) | capabilities: `setup.access`, `studio.access`, `manage_metadata` | -The built-in profiles already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` and `organization_admin` do (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)). +The built-in permission sets already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` and `organization_admin` do (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)). ### 1. Gate the app and its navigation @@ -40,7 +40,7 @@ defineApp({ Each nav item supports three independent gates ([`app.zod`](/docs/references/ui/app)): - `requiredPermissions` — RBAC capability (e.g. `manage_metadata`). -- `visible` — a CEL predicate (e.g. `P\`'org_admin' in current_user.roles\``). +- `visible` — a CEL predicate (e.g. `P\`'org_admin' in current_user.positions\``). - `requiresObject` / `requiresService` — hide unless a runtime object/service is installed. **Hide, don't disable.** A disabled-but-visible builder entry is still noise and still confuses end users. Gated nav items are *not rendered* for users who lack the capability. @@ -55,11 +55,11 @@ Use a [curated view](/docs/ui/field-grouping-and-order) (selected fields, progre ## Why -This is the lesson from Airtable's surfaces: the **Automations tab is visible to every base collaborator, even read-only** — so end users see builder chrome they can't use and don't understand. ObjectStack avoids that by making "consumer vs builder" a capability boundary that the built-in profiles already draw, plus per-nav gating that *omits* (not merely disables) what a user can't use. Action gates are **dual-surface** (ADR-0066 D4): the ActionRunner hides/disables an action in the UI **and** the server rejects the call — no "UI-gated but server-open" footgun. +This is the lesson from Airtable's surfaces: the **Automations tab is visible to every base collaborator, even read-only** — so end users see builder chrome they can't use and don't understand. ObjectStack avoids that by making "consumer vs builder" a capability boundary that the built-in permission sets already draw, plus per-nav gating that *omits* (not merely disables) what a user can't use. Action gates are **dual-surface** (ADR-0066 D4): the ActionRunner hides/disables an action in the UI **and** the server rejects the call — no "UI-gated but server-open" footgun. ## Runnable example -- Profiles: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — `member_default` / `viewer_readonly` vs `organization_admin` / `admin_full_access`. +- Permission sets: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — `member_default` / `viewer_readonly` vs `organization_admin` / `admin_full_access`. - Apps: [`examples/app-showcase/src/apps`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/apps). ## Anti-patterns diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index d410219d15..691f4ee454 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -4,7 +4,8 @@ import { definePosition } from '@objectstack/spec/identity'; import { definePermissionSet } from '@objectstack/spec/security'; /** - * Example roles — a small sales hierarchy. + * Example positions — flat distribution groups for a small sales team + * (positions carry no hierarchy — ADR-0090 D3). */ export const SalesRepPosition = definePosition({ name: 'sales_rep', diff --git a/examples/app-showcase/access-matrix.json b/examples/app-showcase/access-matrix.json new file mode 100644 index 0000000000..c2411fbd6d --- /dev/null +++ b/examples/app-showcase/access-matrix.json @@ -0,0 +1,347 @@ +{ + "version": 1, + "entries": [ + { + "permissionSet": "showcase_auditor", + "object": "showcase_inquiry", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": true, + "modifyAllRecords": false, + "sharingModel": "private" + }, + { + "permissionSet": "showcase_auditor", + "object": "showcase_invoice", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": true, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_auditor", + "object": "showcase_invoice_line", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": true, + "modifyAllRecords": false, + "sharingModel": "controlled_by_parent" + }, + { + "permissionSet": "showcase_auditor", + "object": "showcase_private_note", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": true, + "modifyAllRecords": false, + "sharingModel": "private" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_account", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_invoice", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_invoice_line", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "controlled_by_parent" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_project", + "create": false, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_contributor", + "object": "showcase_task", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_executive", + "object": "showcase_inquiry", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "org", + "sharingModel": "private" + }, + { + "permissionSet": "showcase_executive", + "object": "showcase_private_note", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "org", + "sharingModel": "private" + }, + { + "permissionSet": "showcase_field_ops_delegate", + "object": "sys_business_unit", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + { + "permissionSet": "showcase_field_ops_delegate", + "object": "sys_business_unit_member", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + { + "permissionSet": "showcase_field_ops_delegate", + "object": "sys_permission_set", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + { + "permissionSet": "showcase_field_ops_delegate", + "object": "sys_position", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + { + "permissionSet": "showcase_field_ops_delegate", + "object": "sys_user", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false + }, + { + "permissionSet": "showcase_field_ops_delegate", + "object": "sys_user_position", + "create": true, + "read": true, + "edit": true, + "delete": true, + "viewAllRecords": false, + "modifyAllRecords": false + }, + { + "permissionSet": "showcase_guest_portal", + "object": "showcase_announcement", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read" + }, + { + "permissionSet": "showcase_guest_portal", + "object": "showcase_inquiry", + "create": true, + "read": false, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "private" + }, + { + "permissionSet": "showcase_manager", + "object": "showcase_contact", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "org", + "sharingModel": "private" + }, + { + "permissionSet": "showcase_manager", + "object": "showcase_inquiry", + "create": false, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "org", + "writeScope": "own", + "sharingModel": "private" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_account", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_announcement", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_inquiry", + "create": true, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "private" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_private_note", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "private" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_product", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_project", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_member_default", + "object": "showcase_task", + "create": true, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + }, + { + "permissionSet": "showcase_ops", + "object": "showcase_announcement", + "create": true, + "read": true, + "edit": true, + "delete": true, + "viewAllRecords": false, + "modifyAllRecords": true, + "sharingModel": "public_read" + }, + { + "permissionSet": "showcase_ops", + "object": "showcase_inquiry", + "create": false, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "readScope": "org", + "writeScope": "org", + "sharingModel": "private" + }, + { + "permissionSet": "showcase_ops", + "object": "showcase_invoice", + "create": false, + "read": true, + "edit": false, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "public_read_write" + } + ] +} diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index b450f35881..b5f89ea99c 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -121,10 +121,15 @@ export const KIND_COVERAGE: Record = { // ── security ── permission: { status: 'demonstrated', - files: ['src/security/index.ts'], - notes: 'Includes MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).', + files: ['src/security/permission-sets.ts'], + notes: + 'Full ADR-0090 authoring surface: CRUD+FLS+RLS, scope depth (own/org read-write asymmetry; hierarchy depths are enterprise hierarchy-security), VAMA, system permissions, the isDefault everyone-suggestion (D5), guest-safe capability (D9), and adminScope delegated administration (D12). Snapshot-gated by access-matrix.json (D6). tabPermissions is not demoable in a single-app package (ADR-0019 D3).', + }, + position: { + status: 'demonstrated', + files: ['src/security/positions.ts'], + notes: 'Flat positions only (no hierarchy — ADR-0090 D3); everyone/guest are built-in anchors, never declared.', }, - position: { status: 'demonstrated', files: ['src/security/index.ts'] }, // ── ai ── agent: { @@ -235,7 +240,8 @@ export const COVERAGE = { 'automation/flows/index.ts — CRUD quartet (create: InboundTaskWebhookFlow, update: ReassignWizardFlow, get+delete: InquiryPurgeFlow), screen/approval/wait/subflow/map/connector_action across the chain; BPMN gateway/boundary forms waived (FLOW_NODE_WAIVERS) in favor of the ADR-0031 structured containers.', }, capabilityChains: { - security: 'security/index.ts — roles + permission set (CRUD + FLS + RLS) + sharing + policy', + security: + 'security/* — positions + permission sets (CRUD + FLS + RLS + depth + VAMA + system/tab permissions + adminScope) + sharing rules (position & BU-subtree recipients) + per-object OWD/externalSharingModel + seeded sys_business_unit tree + access-matrix.json gate (ADR-0090)', automation: 'automation/flows/index.ts (incl. approval nodes) + automation/webhooks/index.ts + automation/jobs/index.ts + system/emails/index.ts', }, i18nThemingPortals: { diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index a930a391c8..8145b9e039 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -18,6 +18,10 @@ export const Account = ObjectSchema.create({ // object is RLS-owned / intentionally public; without this the new secure // default (unset OWD => private) would owner-filter it. sharingModel: 'public_read_write', + // [ADR-0090 D11] External principals may READ accounts (portal users see + // the customer directory) but never write them — a non-default external + // dial that is still strictly ≤ the internal `public_read_write`. + externalSharingModel: 'public_read', label: 'Account', pluralLabel: 'Accounts', icon: 'building', diff --git a/examples/app-showcase/src/data/objects/announcement.object.ts b/examples/app-showcase/src/data/objects/announcement.object.ts index de216597d2..0ed9376792 100644 --- a/examples/app-showcase/src/data/objects/announcement.object.ts +++ b/examples/app-showcase/src/data/objects/announcement.object.ts @@ -20,9 +20,16 @@ export const Announcement = ObjectSchema.create({ icon: 'megaphone', description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).', - // Everyone reads; owner writes. Canonical OWD name (ADR-0056 D1); `read` is - // the legacy alias. No RLS authored. + // Everyone reads; owner writes. Canonical OWD vocabulary only — the legacy + // aliases were removed from the enum (ADR-0090 D4). No RLS authored. sharingModel: 'public_read', + // [ADR-0090 D11] The EXTERNAL dial: portal/partner principals + // (`audience: 'external'`) get their own, stricter baseline. Internal + // announcements are public_read for employees but PRIVATE to external + // users — an external portal user sees only announcements they own or were + // explicitly shared. Must never be wider than the internal model + // (validated: external ≤ internal). + externalSharingModel: 'private', fields: { title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }), diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 6309a8a0d5..278d97a1a7 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { defineSeed } from '@objectstack/spec/data'; +import { defineSeed, SeedSchema } from '@objectstack/spec/data'; import { cel } from '@objectstack/spec'; import { Account } from '../objects/account.object.js'; import { Preference } from '../objects/preference.object.js'; @@ -130,6 +130,37 @@ const businessUnits = defineSeed(BusinessUnit, { ], }); +/** + * The REAL org tree — `sys_business_unit` rows (platform identity object), + * distinct from the `showcase_business_unit` DEMO object above (which only + * feeds the `tree` view). This tree is what the ADR-0090 permission model + * actually evaluates against: + * • depth grants (`readScope: 'unit' / 'unit_and_below'`) resolve membership + * through it (Setup → Access Control → Business Units, org-chart view); + * • the `share_new_inquiries_with_field_ops` sharing rule expands the + * `bu_field_ops` SUBTREE (Field Operations + West/East Coast); + * • the `showcase_field_ops_delegate` adminScope is bounded by it. + * + * Seeded with EXPLICIT ids so metadata can reference units statically (the + * sharing-rule recipient wants the row id; the adminScope wants the `name`). + * The tree is normally environment-owned admin data — seeding it here plays + * the admin's part so the permission demos work on a fresh boot. Users can't + * be seeded (they sign up), so user↔unit membership (`sys_business_unit_member`) + * and position assignments stay runtime admin actions. + */ +const orgUnits = SeedSchema.parse({ + object: 'sys_business_unit', + mode: 'upsert', + externalId: 'id', + records: [ + { id: 'bu_acme', name: 'Acme Corporation', code: 'ACME', kind: 'company', active: true }, + { id: 'bu_field_ops', name: 'Field Operations', code: 'FOPS', kind: 'division', parent_business_unit_id: 'bu_acme', active: true }, + { id: 'bu_west_coast', name: 'West Coast', code: 'FOPS-W', kind: 'office', parent_business_unit_id: 'bu_field_ops', active: true }, + { id: 'bu_east_coast', name: 'East Coast', code: 'FOPS-E', kind: 'office', parent_business_unit_id: 'bu_field_ops', active: true }, + { id: 'bu_hq_finance', name: 'HQ Finance', code: 'FIN', kind: 'department', parent_business_unit_id: 'bu_acme', active: true }, + ], +}); + const teams = defineSeed(Team, { mode: 'upsert', externalId: 'name', @@ -255,4 +286,4 @@ const preferences = defineSeed(Preference, { ], }); -export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences]; +export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences]; diff --git a/examples/app-showcase/src/docs/showcase_tour_security.md b/examples/app-showcase/src/docs/showcase_tour_security.md index e6a1e4036c..8fd0d2c48a 100644 --- a/examples/app-showcase/src/docs/showcase_tour_security.md +++ b/examples/app-showcase/src/docs/showcase_tour_security.md @@ -1,38 +1,108 @@ --- title: "Tour · Security" -description: Guided tour of the security domain — roles, permission sets, the default profile, sharing rules, and row-level security. +description: Guided tour of the security domain — positions, permission sets, scope depth, VAMA, audience anchors, sharing rules, delegated administration, and the access-matrix gate. --- # Guided tour — Security -Everything in this domain lives under `src/security/index.ts`. +The showcase ships the complete ADR-0090 permission model. The whole model in +four sentences: a user's **capability** is the *union* of every **permission +set** they hold; **positions** decide who holds which sets; the +**business-unit tree** and manager chain decide *how deep* a grant sees; each +object's **OWD** (`sharingModel`) sets the record baseline — **sharing** only +widens it, **RLS** only narrows it. -## Roles, permission sets, profile +Everything lives under `src/security/` (positions, permission sets, sharing +rules), `src/data/objects/` (per-object OWD), and `src/data/seed/` (the +`sys_business_unit` org tree). -The showcase ships a role hierarchy, permission sets with object CRUD + -field-level security + row-level security, and `showcase_member_default` — -a permission set with `isProfile: true`, the fallback **profile** -(ADR-0056). +## Capability — permission sets -The `showcase_contributor` permission set as a live object-access matrix: +`showcase_contributor` layers object CRUD + field-level security (budget +fields read-only) + row-level security (own tasks/invoices only, plus an +ADR-0058 D4 write-time `check`): ```metadata type: permission name: showcase_contributor ``` -## Sharing +The other sets each demonstrate one axis: -Sharing rules extend record access beyond ownership — see the -`sharingRules` wired in `objectstack.config.ts` and the private-note -object for an owner-only counter-example. +- `showcase_manager` — **scope depth** (ADR-0057 D1) with read/write + asymmetry: org-wide read over inquiries, edit own only (`readScope: + 'org'`, `writeScope: 'own'`). The intermediate hierarchy depths + (`own_and_reports`/`unit`/`unit_and_below`) are enterprise + (`hierarchy-security`); the open edition demonstrates BU-shaped visibility + via the sharing rule below instead. +- `showcase_executive` — org-wide read via `readScope: 'org'` (depth, not a + bypass). +- `showcase_auditor` — **View-All** (`viewAllRecords`): reads every private + record, holds no write bit. High-privilege: the publish linter blocks it + from `everyone`/`guest` bindings. +- `showcase_ops` — **system permissions** (`setup.access` opens the Setup + app for non-admins — apps declaring `requiredPermissions` appear in + `/me/apps` only when the caller's union carries them) and **Modify-All** + on announcements (a `public_read` object, so the bypass matters). +- `showcase_member_default` — `isDefault: true`, the **`everyone` + suggestion** (ADR-0090 D5): the read-mostly baseline every authenticated + member holds *additively* (no fallback cliff). +- `showcase_guest_portal` — guest-safe capability (read announcements, + create inquiries) for the built-in **`guest`** position (ADR-0090 D9); + binding it is an admin action in Setup. +- `showcase_field_ops_delegate` — **delegated administration** + (ADR-0090 D12): an `adminScope` bounded to the Field Operations subtree + with an assignable-set allowlist; no self-escalation. + +## Record baseline — OWD, internal and external + +Every object declares its `sharingModel` explicitly (the unset state no +longer exists — ADR-0090 D1). The showcase covers all four canonical values: +`showcase_private_note` (`private`), `showcase_announcement` (`public_read`), +most demo objects (`public_read_write`), and `showcase_invoice_line` +(`controlled_by_parent`). Two objects also declare the **external dial** +(`externalSharingModel`, ADR-0090 D11): announcements are `private` to +portal users; accounts are `public_read`. + +## Widening — sharing rules + +Criteria rules compile their CEL condition to an enforced filter and +materialize `sys_record_share` grants (ADR-0058 D3). Recipients demonstrate +both enforced kinds: `position` (red projects → execs; compound-condition +high-value red projects → managers) and `unit_and_subordinates` +(new inquiries → the Field Operations **business-unit subtree**). The +owner-based rule is kept as an authoring-shape example only — it is skipped +at seed time (`[experimental]`, ADR-0049: nothing silently over-shares). + +## The org tree + +`src/data/seed/` seeds real `sys_business_unit` rows (Acme → Field +Operations → West/East Coast; HQ Finance) with explicit ids so metadata can +reference them. Browse it in Setup → Access Control → Business Units +(org-chart view). User↔unit membership and position assignments are runtime +admin actions — users sign up, they are not seeded. + +## The gate — access matrix + +`access-matrix.json` next to `objectstack.config.ts` is the ADR-0090 D6 +snapshot: `objectstack compile` derives the (permission set × object) matrix +from the declarations above and **fails the build on drift** with a semantic +diff ("`showcase_auditor` gains delete on `showcase_invoice`") until the +snapshot is regenerated with `--update-access-matrix`. The snapshot's git +diff is the review artifact. The same build runs the ADR-0090 D7 security +posture linter (unset OWD, retired aliases, external-wider-than-internal, +wildcard VAMA, high-privilege anchor suggestions, forbidden vocabulary). ## See it enforced -Log in as a non-admin member (create one in Setup → Users, assign the -member profile): the Field Zoo's permission-gated fields mask, private -notes vanish, and write attempts outside your row scope are rejected — -the declared model, enforced end to end. +Log in as a non-admin member (create one in Setup → Users): the Field Zoo's +permission-gated fields mask, private notes vanish, and write attempts +outside your row scope are rejected — while a user assigned the `auditor` +position reads everything and the Setup app appears for `ops` holders. +Ask "why" with the explain engine — the `security` +service's `explain({ object, operation, userId })` reports every layer's +verdict with contributor attribution, walking the same code the middleware +enforces with. This is the last stop — back to the [overview](./showcase_index.md), or jump to the [Data tour](./showcase_tour_data.md) to start again. diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index 01bf192e01..b03e16b494 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -1,176 +1,53 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Security capability chain — a role hierarchy, a permission set that layers - * object CRUD + field-level security (FLS) + row-level security (RLS), two - * sharing rules (criteria- and owner-based), and an org security policy. - * Together these exercise RBAC, FLS, RLS, and sharing in one place. These are - * plain objects validated by `defineStack` (which coerces string predicates - * and fills CRUD defaults). + * Security domain — the ADR-0090 Permission Model v2, end to end. + * + * Five concepts, one file each concern: + * • positions.ts — flat distribution groups (who gets which sets) + * • permission-sets.ts — capability (CRUD, FLS, RLS, depth, VAMA, system + * permissions, tabs, everyone-suggestion, guest + * capability, delegated-admin scope) + * • sharing-rules.ts — record widening (criteria + BU-subtree recipients) + * + * The other two concepts live elsewhere by design: the OWD baseline + * (`sharingModel` / `externalSharingModel`) is declared per-object in + * src/data/objects/, and the business-unit tree is DATA (environment-owned, + * seeded in src/data/seed/ as `sys_business_unit` rows), not metadata. + * + * The committed `access-matrix.json` next to objectstack.config.ts is the + * ADR-0090 D6 snapshot gate: `objectstack compile` derives the + * (permission set × object) matrix from these declarations and fails the + * build on drift until the snapshot is regenerated with + * `--update-access-matrix` — the snapshot's git diff is the review artifact. */ -// ── Roles (hierarchy) ────────────────────────────────────────────────────── -export const ContributorPosition = { - name: 'contributor', - label: 'Contributor', - description: 'Works tasks on their own projects.', -}; - -export const ManagerPosition = { - name: 'manager', - label: 'Project Manager', - description: 'Manages projects and the contributors on them.', - parent: 'contributor', -}; - -export const ExecPosition = { - name: 'exec', - label: 'Executive', - description: 'Read-all visibility for reporting.', - parent: 'manager', -}; - -// ── Permission set: CRUD + FLS + RLS ────────────────────────────────────── -export const ContributorPermissionSet = { - name: 'showcase_contributor', - label: 'Showcase Contributor', - description: 'Standard access for contributors, with budget fields hidden and row-level scoping to own records.', - objects: { - showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false }, - showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, - // Invoice graph: contributors fully manage invoices + their lines. Read/write - // is scoped by the owner RLS below (invoice) and DERIVED for the lines, which - // are `controlled_by_parent` — no line RLS is authored (ADR-0055). - showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - }, - // Field-level security — contributors can read but not edit budget figures. - fields: { - budget: { readable: true, editable: false }, - spent: { readable: true, editable: false }, - budget_remaining: { readable: true, editable: false }, - }, - // Row-level security — contributors only see tasks assigned to them. - rowLevelSecurity: [ - { - name: 'task_own_rows', - label: 'Own Tasks Only', - description: 'Contributors can only select tasks assigned to them.', - object: 'showcase_task', - operation: 'select' as const, - using: "assignee == current_user.email", - positions: ['contributor'], - enabled: true, - priority: 10, - }, - // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is - // `controlled_by_parent`, a contributor seeing only their own invoices also - // sees only those invoices' lines — and can by-id read/write a line only when - // they can read/write its master (ADR-0055). No line rule is authored here. - { - name: 'invoice_own_rows', - label: 'Own Invoices Only', - description: "Contributors only see invoices they own; their lines follow via controlled_by_parent.", - object: 'showcase_invoice', - operation: 'select' as const, - using: "owner == current_user.email", - positions: ['contributor'], - enabled: true, - priority: 10, - }, - // [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read - // filter). On UPDATE the new row must still be owned by the caller, so a - // contributor cannot reassign an invoice they own to someone else. `check` - // is compiled by the canonical CEL compiler and matched against the post- - // image (pre-image ∪ change set); a violating write is denied (fail-closed). - { - name: 'invoice_owner_immutable', - label: 'Invoice Owner Cannot Be Reassigned', - description: 'A contributor cannot change an invoice they own to a different owner (write-time CHECK, ADR-0058 D4).', - object: 'showcase_invoice', - operation: 'update' as const, - check: "owner == current_user.email", - positions: ['contributor'], - enabled: true, - priority: 10, - }, - ], -}; - -// ── App-declared DEFAULT PROFILE (ADR-0056 D7) ────────────────────────────── -/** - * The showcase's default access posture for a freshly signed-up user who holds - * no explicit grants. `isDefault: true` makes the app declare what "a new member - * can do" instead of inheriting the built-in `member_default` wildcard. The CLI - * (`pnpm dev`) reads this off the stack and wires it as the SecurityPlugin - * fallback (ADR-0056 D7) — without that wiring an `isDefault` flag in app - * metadata is silently ignored. Deliberately read-mostly: a brand-new member can - * browse the shared catalog + announcements and file tasks/inquiries, but cannot - * edit or delete anyone's records (owner/OWD enforcement still applies on top). - */ -export const MemberDefaultProfile = { - name: 'showcase_member_default', - label: 'Showcase Member (Default)', - description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).', - isDefault: true, - objects: { - showcase_account: { allowRead: true }, - showcase_product: { allowRead: true }, - showcase_project: { allowRead: true }, - showcase_task: { allowRead: true, allowCreate: true }, - showcase_announcement: { allowRead: true }, - showcase_inquiry: { allowRead: true, allowCreate: true }, - }, -}; - -// ── Sharing rules ────────────────────────────────────────────────────────── -/** criteria-based: red-health projects are shared up to executives. */ -export const RedProjectSharingRule = { - type: 'criteria' as const, - name: 'share_red_projects_with_execs', - label: 'Red Projects → Executives', - description: 'Automatically share at-risk (red health) projects with executives.', - object: 'showcase_project', - condition: "record.health == 'red'", - accessLevel: 'read' as const, - sharedWith: { type: 'position' as const, value: 'exec' }, - active: true, -}; - -/** - * [ADR-0058 D3 / closes #1887] criteria-based with a COMPOUND CEL condition. - * Before #1887 a multi-clause `&&` condition was silently skipped (the sharing - * rule was decorative metadata); now it compiles to a compound `criteria_json` - * and enforces. Shares only projects that are BOTH at-risk (red) AND high-budget - * with managers — the AND matters: a red but low-budget project is NOT shared. - */ -export const HighValueRedProjectRule = { - type: 'criteria' as const, - name: 'share_high_value_red_projects_with_managers', - label: 'High-Value Red Projects → Managers', - description: 'Share at-risk (red health) projects over the budget threshold with managers (compound condition, ADR-0058 D3).', - object: 'showcase_project', - condition: "record.health == 'red' && record.budget > 100000", - accessLevel: 'read' as const, - sharedWith: { type: 'position' as const, value: 'manager' }, - active: true, -}; - -/** owner-based: a contributor's tasks are shared read-only with managers. */ -export const ContributorTaskSharingRule = { - type: 'owner' as const, - name: 'share_contributor_tasks_with_manager', - label: "Contributor Tasks → Manager", - description: "Share each contributor's tasks with managers for oversight.", - object: 'showcase_task', - ownedBy: { type: 'position' as const, value: 'contributor' }, - accessLevel: 'read' as const, - sharedWith: { type: 'position' as const, value: 'manager' }, - active: true, -}; - - -export const allPositions = [ContributorPosition, ManagerPosition, ExecPosition]; -export const allPermissionSets = [ContributorPermissionSet, MemberDefaultProfile]; -export const allSharingRules = [RedProjectSharingRule, HighValueRedProjectRule, ContributorTaskSharingRule]; +export { + ContributorPosition, + ManagerPosition, + ExecPosition, + AuditorPosition, + OpsPosition, + FieldOpsDelegatePosition, + allPositions, +} from './positions.js'; + +export { + ContributorPermissionSet, + ManagerPermissionSet, + ExecutivePermissionSet, + AuditorPermissionSet, + OpsPermissionSet, + MemberDefaultPermissionSet, + GuestPortalPermissionSet, + FieldOpsDelegatePermissionSet, + allPermissionSets, +} from './permission-sets.js'; + +export { + RedProjectSharingRule, + HighValueRedProjectRule, + NewInquiryFieldOpsRule, + ContributorTaskSharingRule, + allSharingRules, +} from './sharing-rules.js'; diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts new file mode 100644 index 0000000000..93069c7fce --- /dev/null +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Permission sets — the CAPABILITY layer of the ADR-0090 permission model. + * + * A user's capability is the UNION of every set they hold (additive only — + * to withhold, don't grant; there are no subtraction sets). Together these + * sets exercise the full authoring surface: + * + * • object CRUD + field-level security (FLS) + row-level security (RLS) + * — `showcase_contributor` + * • scope DEPTH (`readScope`/`writeScope`, ADR-0057 D1; the open-edition + * dials `own`/`org` — hierarchy depths are enterprise) — + * `showcase_manager`, `showcase_executive` + * • View/Modify-All bypass (VAMA) — `showcase_auditor`, `showcase_ops` + * • system permissions (platform capabilities) — `showcase_ops` + * • the `everyone` baseline suggestion (`isDefault`, ADR-0090 D5) — + * `showcase_member_default` + * • guest-safe capability for the `guest` anchor (ADR-0090 D9) — + * `showcase_guest_portal` + * • delegated administration (`adminScope`, ADR-0090 D12) — + * `showcase_field_ops_delegate` + * + * DEPTH vs VAMA, in one line: depth widens a grant along the org geometry + * (BU tree + manager chain) and still flows through sharing/RLS; VAMA + * bypasses record-level checks outright and is therefore high-privilege + * (lint-blocked on `everyone`/`guest` bindings). + */ + +import { definePermissionSet } from '@objectstack/spec/security'; + +// ── CRUD + FLS + RLS ─────────────────────────────────────────────────────── +export const ContributorPermissionSet = definePermissionSet({ + name: 'showcase_contributor', + label: 'Showcase Contributor', + objects: { + showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false }, + showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + // Invoice graph: contributors fully manage invoices + their lines. Read/write + // is scoped by the owner RLS below (invoice) and DERIVED for the lines, which + // are `controlled_by_parent` — no line RLS is authored (ADR-0055). + showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + }, + // Field-level security — contributors can read but not edit budget figures. + fields: { + budget: { readable: true, editable: false }, + spent: { readable: true, editable: false }, + budget_remaining: { readable: true, editable: false }, + }, + // Row-level security — contributors only see tasks assigned to them. + rowLevelSecurity: [ + { + name: 'task_own_rows', + label: 'Own Tasks Only', + description: 'Contributors can only select tasks assigned to them.', + object: 'showcase_task', + operation: 'select' as const, + using: 'assignee == current_user.email', + positions: ['contributor'], + enabled: true, + priority: 10, + }, + // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is + // `controlled_by_parent`, a contributor seeing only their own invoices also + // sees only those invoices' lines — and can by-id read/write a line only when + // they can read/write its master (ADR-0055). No line rule is authored here. + { + name: 'invoice_own_rows', + label: 'Own Invoices Only', + description: + 'Contributors only see invoices they own; their lines follow via controlled_by_parent.', + object: 'showcase_invoice', + operation: 'select' as const, + using: 'owner == current_user.email', + positions: ['contributor'], + enabled: true, + priority: 10, + }, + // [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read + // filter). On UPDATE the new row must still be owned by the caller, so a + // contributor cannot reassign an invoice they own to someone else. `check` + // is compiled by the canonical CEL compiler and matched against the post- + // image (pre-image ∪ change set); a violating write is denied (fail-closed). + { + name: 'invoice_owner_immutable', + label: 'Invoice Owner Cannot Be Reassigned', + description: + 'A contributor cannot change an invoice they own to a different owner (write-time CHECK, ADR-0058 D4).', + object: 'showcase_invoice', + operation: 'update' as const, + check: 'owner == current_user.email', + positions: ['contributor'], + enabled: true, + priority: 10, + }, + ], +}); + +// ── Scope depth (ADR-0057 D1) ────────────────────────────────────────────── +/** + * Depth widens a grant on PRIVATE objects along the org geometry — a public + * object's baseline is already org-wide, so depth only matters where the + * baseline stops. Managers demonstrate the READ/WRITE ASYMMETRY the two + * dials allow: org-wide read over inquiries, but edit only their own + * (`readScope: 'org'`, `writeScope: 'own'`). + * + * The intermediate HIERARCHY depths (`own_and_reports` / `unit` / + * `unit_and_below`) are an enterprise capability (`requires: + * ['hierarchy-security']`, shipped by @objectstack/security-enterprise) — + * the open edition fails closed to owner-only, so this open example does not + * author them (declared ≠ enforced is the one thing a showcase must never + * do). BUSINESS-UNIT-shaped visibility in the open edition is instead + * demonstrated by the `share_new_inquiries_with_field_ops` sharing rule + * (BU-subtree recipient — see sharing-rules.ts). + */ +export const ManagerPermissionSet = definePermissionSet({ + name: 'showcase_manager', + label: 'Showcase Manager', + objects: { + showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'own' }, + showcase_contact: { allowRead: true, readScope: 'org' }, + }, +}); + +/** + * Executives read org-wide via DEPTH (`readScope: 'org'`) — contrast with the + * auditor's VAMA below: `org` depth still respects RLS narrowing; VAMA is a + * record-level bypass. + */ +export const ExecutivePermissionSet = definePermissionSet({ + name: 'showcase_executive', + label: 'Showcase Executive', + objects: { + showcase_private_note: { allowRead: true, readScope: 'org' }, + showcase_inquiry: { allowRead: true, readScope: 'org' }, + }, +}); + +// ── VAMA: View-All / Modify-All (record-level bypass) ───────────────────── +/** + * Compliance read-only: `viewAllRecords` bypasses OWD/sharing/depth on the + * named objects — the auditor sees every private note, inquiry, and invoice, + * but holds no write bit anywhere. High-privilege by definition: the D7 lint + * blocks binding this set to the `everyone`/`guest` anchors. + */ +export const AuditorPermissionSet = definePermissionSet({ + name: 'showcase_auditor', + label: 'Showcase Auditor', + objects: { + showcase_private_note: { allowRead: true, viewAllRecords: true }, + showcase_inquiry: { allowRead: true, viewAllRecords: true }, + showcase_invoice: { allowRead: true, viewAllRecords: true }, + showcase_invoice_line: { allowRead: true, viewAllRecords: true }, + }, +}); + +// ── System permissions + Modify-All ──────────────────────────────────────── +/** + * Back-office operations. Two distinct capabilities on one set: + * • `systemPermissions: ['setup.access']` — platform capability + * (ADR-0066): ops users can open Setup without being platform admins. + * System permissions also drive app-tab reachability: an app declaring + * `requiredPermissions` (Setup does) only appears in `/me/apps` when the + * union of the caller's sets carries every listed capability. + * • `modifyAllRecords` on announcements — announcements are OWD + * `public_read` (owner-writes-only), so Modify-All is what lets ops fix + * ANYONE's announcement. On an already-`public_read_write` object it + * would grant nothing — bypasses only matter where the baseline stops. + * + * (`tabPermissions` — per-app visible/hidden votes — is deliberately NOT + * demoed here: an 'app'-type package carries at most one app (ADR-0019 D3), + * and hiding someone else's platform app would demo against surface this + * package doesn't own. The enforced consumer is `/me/apps`, which drops apps + * whose merged vote is `hidden`.) + */ +export const OpsPermissionSet = definePermissionSet({ + name: 'showcase_ops', + label: 'Showcase Operations', + objects: { + showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, modifyAllRecords: true }, + showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' }, + showcase_invoice: { allowRead: true }, + }, + systemPermissions: ['setup.access'], +}); + +// ── The `everyone` baseline suggestion (ADR-0090 D5) ─────────────────────── +/** + * `isDefault: true` is a SUGGESTION: "bind this set to the built-in + * `everyone` position" — consumed at install time, never auto-bound in a + * package install (the admin confirms). The dev CLI wires it as the additive + * per-request baseline, so every authenticated member holds it IN ADDITION + * to their explicit grants (no fallback cliff — a first explicit grant does + * not cost the baseline; ADR-0090 D5 abolished that). + * + * Deliberately read-mostly and low-privilege: the D7 lint hard-blocks + * high-privilege bits (VAMA, delete/purge/transfer, system permissions, + * wildcards) on any everyone-suggested set. + */ +export const MemberDefaultPermissionSet = definePermissionSet({ + name: 'showcase_member_default', + label: 'Showcase Member (Default)', + isDefault: true, + objects: { + showcase_account: { allowRead: true }, + showcase_product: { allowRead: true }, + showcase_project: { allowRead: true }, + showcase_task: { allowRead: true, allowCreate: true }, + showcase_announcement: { allowRead: true }, + showcase_inquiry: { allowRead: true, allowCreate: true }, + // Personal data on a `private`-OWD object: every member may keep private + // notes, and the OWD baseline — not the grant — is what keeps them + // owner-scoped (ADR-0090 D1: granting Read never means reading others'). + // The D7 linter flags this owner-only read as `security-private-no- + // readscope` (info) — here it is exactly the intent. + showcase_private_note: { allowRead: true, allowCreate: true, allowEdit: true }, + }, +}); + +// ── Guest-safe capability (ADR-0090 D9) ──────────────────────────────────── +/** + * Capability shaped for the built-in `guest` position: anonymous visitors + * may read announcements and file an inquiry (public form intake) — nothing + * else. Guest bindings face the STRICTEST lint tier: named objects only (no + * wildcard), read-only by default (create case-by-case), never `allowEdit`, + * never VAMA/system permissions. The runtime anchor gate + * (`assertAudienceAnchorBindingGate`) enforces the same rules on the binding + * write itself. + * + * Binding is an ADMIN action (Setup → Access Control → bind `guest` → + * `showcase_guest_portal`); the app only ships the capability. + */ +export const GuestPortalPermissionSet = definePermissionSet({ + name: 'showcase_guest_portal', + label: 'Showcase Guest Portal', + objects: { + showcase_announcement: { allowRead: true }, + showcase_inquiry: { allowCreate: true }, + }, +}); + +// ── Delegated administration (ADR-0090 D12) ──────────────────────────────── +/** + * An admin scope makes ADMINISTRATION ITSELF a scoped grant: the holder may + * manage user↔position assignments INSIDE the Field Operations business-unit + * subtree (seeded in src/data/seed/ as `bu_field_ops` + descendants), and may + * only hand out the sets on the allowlist — to others OR themselves (no + * self-escalation, enforced by the runtime `DelegatedAdminGate`). + * + * The `adminScope` authorizes WHAT may be administered; the plain CRUD bits + * on the RBAC link tables below let the requests through at all (both are + * required — holders of table CRUD with NO scope are refused). + * `manageBindings` stays false: this delegate re-staffs positions, they do + * not re-compose what a position means. + */ +export const FieldOpsDelegatePermissionSet = definePermissionSet({ + name: 'showcase_field_ops_delegate', + label: 'Field Ops Delegate Admin', + objects: { + sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + sys_position: { allowRead: true }, + sys_permission_set: { allowRead: true }, + sys_business_unit: { allowRead: true }, + sys_business_unit_member: { allowRead: true }, + sys_user: { allowRead: true }, + }, + adminScope: { + businessUnit: 'Field Operations', + includeSubtree: true, + manageAssignments: true, + manageBindings: false, + authorEnvironmentSets: false, + assignablePermissionSets: ['showcase_contributor', 'showcase_manager'], + }, +}); + +export const allPermissionSets = [ + ContributorPermissionSet, + ManagerPermissionSet, + ExecutivePermissionSet, + AuditorPermissionSet, + OpsPermissionSet, + MemberDefaultPermissionSet, + GuestPortalPermissionSet, + FieldOpsDelegatePermissionSet, +]; diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts new file mode 100644 index 0000000000..c095ccea85 --- /dev/null +++ b/examples/app-showcase/src/security/positions.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Positions — the DISTRIBUTION layer of the ADR-0090 permission model. + * + * A position is a flat, job-shaped group (岗位): it answers "who gets which + * permission sets" and nothing else. Deliberately NO hierarchy here — the + * visibility tree is the business-unit tree (see `business-units` in + * src/data/seed/), and the manager chain is `sys_user.manager_id` + * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). + * + * Two positions are BUILT-IN and never declared by an app: `everyone` + * (implicitly held by every authenticated member — the tenant baseline, + * ADR-0090 D5) and `guest` (implicitly held by anonymous visitors, + * ADR-0090 D9). Packages target them by SUGGESTING bindings (`isDefault` on a + * permission set), never by declaring or writing to them. + */ + +import { definePosition } from '@objectstack/spec/identity'; + +/** Works tasks on their own projects — the rank-and-file position. */ +export const ContributorPosition = definePosition({ + name: 'contributor', + label: 'Contributor', + description: 'Works tasks on their own projects.', +}); + +/** Runs a unit: depth-scoped visibility over the unit's private records. */ +export const ManagerPosition = definePosition({ + name: 'manager', + label: 'Project Manager', + description: 'Manages projects and the contributors on them.', +}); + +/** Org-wide read for reporting — depth-based (`readScope: org`), not VAMA. */ +export const ExecPosition = definePosition({ + name: 'exec', + label: 'Executive', + description: 'Read-all visibility for reporting.', +}); + +/** Compliance: View-All bypass (VAMA) — reads everything, changes nothing. */ +export const AuditorPosition = definePosition({ + name: 'auditor', + label: 'Auditor', + description: 'Compliance read-only view across private records (viewAllRecords).', +}); + +/** Back-office: system permissions, Modify-All repairs, the Operations app. */ +export const OpsPosition = definePosition({ + name: 'ops', + label: 'Operations', + description: 'Back-office operations — Setup access, announcement repairs, Operations app.', +}); + +/** + * Delegated administrator of the Field Operations subtree (ADR-0090 D12). + * The capability itself lives on the `showcase_field_ops_delegate` permission + * set (`adminScope`); this position is just how an admin hands it out. + */ +export const FieldOpsDelegatePosition = definePosition({ + name: 'field_ops_delegate', + label: 'Field Ops Delegate Admin', + description: 'Scoped administration of the Field Operations business-unit subtree.', +}); + +export const allPositions = [ + ContributorPosition, + ManagerPosition, + ExecPosition, + AuditorPosition, + OpsPosition, + FieldOpsDelegatePosition, +]; diff --git a/examples/app-showcase/src/security/sharing-rules.ts b/examples/app-showcase/src/security/sharing-rules.ts new file mode 100644 index 0000000000..e6c14cf7b0 --- /dev/null +++ b/examples/app-showcase/src/security/sharing-rules.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Sharing rules — the WIDENING layer of the ADR-0090 permission model. + * + * Each object's OWD (`sharingModel`) is the record-visibility baseline; + * sharing only ever WIDENS it (and RLS only ever narrows). A criteria rule's + * CEL `condition` is compiled to a runtime filter at seed time and + * materializes `sys_record_share` grants for the resolved recipients + * (ADR-0058 D3). Recipients here exercise both enforced recipient kinds: + * `position` (flat holder expansion) and `unit_and_subordinates` + * (business-unit SUBTREE expansion — the unit named by `value` plus every + * descendant unit's members, ADR-0057 D5 / ADR-0090 D3). + */ + +import { defineSharingRule } from '@objectstack/spec/security'; + +/** criteria-based: red-health projects are shared up to executives. */ +export const RedProjectSharingRule = defineSharingRule({ + type: 'criteria', + name: 'share_red_projects_with_execs', + label: 'Red Projects → Executives', + description: 'Automatically share at-risk (red health) projects with executives.', + object: 'showcase_project', + condition: "record.health == 'red'", + accessLevel: 'read', + sharedWith: { type: 'position', value: 'exec' }, + active: true, +}); + +/** + * [ADR-0058 D3 / closes #1887] criteria-based with a COMPOUND CEL condition. + * Before #1887 a multi-clause `&&` condition was silently skipped (the sharing + * rule was decorative metadata); now it compiles to a compound `criteria_json` + * and enforces. Shares only projects that are BOTH at-risk (red) AND high-budget + * with managers — the AND matters: a red but low-budget project is NOT shared. + */ +export const HighValueRedProjectRule = defineSharingRule({ + type: 'criteria', + name: 'share_high_value_red_projects_with_managers', + label: 'High-Value Red Projects → Managers', + description: + 'Share at-risk (red health) projects over the budget threshold with managers (compound condition, ADR-0058 D3).', + object: 'showcase_project', + condition: "record.health == 'red' && record.budget > 100000", + accessLevel: 'read', + sharedWith: { type: 'position', value: 'manager' }, + active: true, +}); + +/** + * Business-unit SUBTREE recipient (`unit_and_subordinates`): new inquiries are + * shared for triage with everyone in the Field Operations unit — AND every + * descendant unit (West Coast, East Coast) via the `sys_business_unit` tree. + * `value` is the business-unit row id (`bu_field_ops`, seeded with an explicit + * id in src/data/seed/ precisely so this rule can reference it statically). + * Inquiries are OWD `private`, so WITHOUT this rule a non-owner member sees + * none; the rule + the member baseline's `allowRead` is what lets Field Ops + * staff read incoming leads. + */ +export const NewInquiryFieldOpsRule = defineSharingRule({ + type: 'criteria', + name: 'share_new_inquiries_with_field_ops', + label: 'New Inquiries → Field Operations (BU subtree)', + description: + 'Share incoming (status=new) inquiries with the Field Operations business-unit subtree for triage.', + object: 'showcase_inquiry', + condition: "record.status == 'new'", + accessLevel: 'read', + sharedWith: { type: 'unit_and_subordinates', value: 'bu_field_ops' }, + active: true, +}); + +/** + * owner-based: a contributor's tasks are shared read-only with managers. + * + * [experimental — not enforced] Owner-type rules depend on live position + * membership and have no static `criteria_json` equivalent, so the seed + * bootstrap SKIPS them (logged) rather than seeding a permissive match-all + * (ADR-0049: nothing silently over-shares). Kept here to demonstrate the + * authoring shape; managers actually reach contributor tasks via the + * criteria rules above and their depth grants. + */ +export const ContributorTaskSharingRule = defineSharingRule({ + type: 'owner', + name: 'share_contributor_tasks_with_manager', + label: 'Contributor Tasks → Manager', + description: "Share each contributor's tasks with managers for oversight.", + object: 'showcase_task', + ownedBy: { type: 'position', value: 'contributor' }, + accessLevel: 'read', + sharedWith: { type: 'position', value: 'manager' }, + active: true, +}); + +export const allSharingRules = [ + RedProjectSharingRule, + HighValueRedProjectRule, + NewInquiryFieldOpsRule, + ContributorTaskSharingRule, +]; diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 4973155d85..4a96dfd064 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -25,7 +25,10 @@ describe('showcase stack', () => { // leaving 3 dataset-bound analytics reports. expect((stack.reports ?? []).length).toBe(3); expect((stack.flows ?? []).length).toBeGreaterThan(0); - expect((stack.positions ?? []).length).toBe(3); + // Six flat positions (contributor/manager/exec/auditor/ops/ + // field_ops_delegate) — the ADR-0090 distribution layer; `everyone` and + // `guest` are built-in anchors and never declared by the app. + expect((stack.positions ?? []).length).toBe(6); expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none }); }); diff --git a/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 1a71797aa2..1685446d6e 100644 --- a/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -52,10 +52,12 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = expect(r.status, 'declared default grants announcement read').toBe(200); }); - it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => { - const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note'); + it('and NOT by the built-in member_default wildcard (contact is denied)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_contact'); // member_default has a wildcard grant → would be 200. The app default grants - // no private_note access → denied, proving the declared default is in force. - expect(r.status, 'declared default does NOT grant private_note').not.toBe(200); + // no contact access → denied, proving the declared default is in force. + // (private_note is no longer a valid canary: the ADR-0090 zoo deliberately + // grants it in the baseline as the personal-data-on-private-OWD demo.) + expect(r.status, 'declared default does NOT grant showcase_contact').not.toBe(200); }); }); diff --git a/packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts new file mode 100644 index 0000000000..451c608639 --- /dev/null +++ b/packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0090 permission-model zoo — runtime guard for the showcase's security +// metadata, in the spirit of `showcase_field_zoo` (#2005) and the semantic +// zoo: the showcase declares the FULL authoring surface (positions, +// CRUD/FLS/RLS sets, org-depth, VAMA, system permissions, everyone/guest +// capability, adminScope, a seeded sys_business_unit tree, BU-subtree +// sharing), and this test proves the SERVED runtime enforces it — not just +// stores it. Each block names the ADR-0090 decision it guards. +// +// @proof: showcase-permission-zoo + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true } as const; + +describe('showcase: ADR-0090 permission-model zoo', () => { + let stack: VerifyStack; + let ql: any; + let adminTok: string; + let ownerTok: string, plainTok: string, auditorTok: string, delegateTok: string; + let ownerId: string, plainId: string, auditorId: string, delegateId: string, targetId: string; + let noteId: string; + + const uid = async (email: string) => + (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + adminTok = await stack.signIn(); + ownerTok = await stack.signUp('zoo-owner@verify.test'); + plainTok = await stack.signUp('zoo-plain@verify.test'); + auditorTok = await stack.signUp('zoo-auditor@verify.test'); + delegateTok = await stack.signUp('zoo-delegate@verify.test'); + await stack.signUp('zoo-target@verify.test'); + + ql = await stack.kernel.getServiceAsync('objectql'); + ownerId = await uid('zoo-owner@verify.test'); + plainId = await uid('zoo-plain@verify.test'); + auditorId = await uid('zoo-auditor@verify.test'); + delegateId = await uid('zoo-delegate@verify.test'); + targetId = await uid('zoo-target@verify.test'); + + // Grant the zoo sets directly (system plumbing — the delegated-admin and + // anchor gates are exercised through REAL authed requests below). + const grant = async (userId: string, setName: string) => { + const ps = await ql.findOne('sys_permission_set', { where: { name: setName }, context: SYS }); + expect(ps?.id, `permission set ${setName} seeded`).toBeTruthy(); + await ql.insert( + 'sys_user_permission_set', + { user_id: userId, permission_set_id: ps.id }, + { context: SYS }, + ); + }; + await grant(auditorId, 'showcase_auditor'); + await grant(delegateId, 'showcase_field_ops_delegate'); + + // Membership: the assignment target sits INSIDE the delegate's subtree + // (West Coast ⊂ Field Operations); the delegate too. + await ql.insert('sys_business_unit_member', { business_unit_id: 'bu_west_coast', user_id: targetId }, { context: SYS }); + await ql.insert('sys_business_unit_member', { business_unit_id: 'bu_field_ops', user_id: delegateId }, { context: SYS }); + + // A private record owned by zoo-owner — the probe for OWD/VAMA. + const created = await stack.apiAs(ownerTok, 'POST', '/data/showcase_private_note', { + title: 'Zoo probe note', + }); + expect(created.status, 'owner creates a private note').toBeLessThan(300); + const body: any = await created.json(); + noteId = body?.id ?? body?.record?.id; + if (!noteId) { + noteId = (await ql.findOne('showcase_private_note', { where: { title: 'Zoo probe note' }, context: SYS }))?.id; + } + expect(noteId, 'probe note id resolved').toBeTruthy(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // ── The app seed can plant the platform org tree ───────────────────────── + it('seeds the sys_business_unit tree with explicit ids and parent links', async () => { + const units = await ql.find('sys_business_unit', { where: {}, context: SYS }); + const byId = new Map((units ?? []).map((u: any) => [u.id, u])); + expect(byId.get('bu_acme'), 'root unit seeded').toBeTruthy(); + expect((byId.get('bu_field_ops') as any)?.parent_business_unit_id).toBe('bu_acme'); + expect((byId.get('bu_west_coast') as any)?.parent_business_unit_id).toBe('bu_field_ops'); + expect((byId.get('bu_hq_finance') as any)?.parent_business_unit_id).toBe('bu_acme'); + }); + + // ── ADR-0090 D12: adminScope lands on the stored set ───────────────────── + it('persists the delegated-admin scope (adminScope → admin_scope JSON)', async () => { + const row = await ql.findOne('sys_permission_set', { where: { name: 'showcase_field_ops_delegate' }, context: SYS }); + expect(row, 'delegate set seeded').toBeTruthy(); + const scope = JSON.parse(row.admin_scope || 'null'); + expect(scope?.businessUnit).toBe('Field Operations'); + expect(scope?.manageAssignments).toBe(true); + expect(scope?.manageBindings).toBe(false); + expect(scope?.assignablePermissionSets).toContain('showcase_contributor'); + }); + + // ── OWD private baseline vs VAMA bypass ─────────────────────────────────── + it('a plain member cannot read someone else’s private note (OWD private, D1)', async () => { + const r = await stack.apiAs(plainTok, 'GET', `/data/showcase_private_note/${noteId}`); + expect(r.status, 'private baseline hides the row').not.toBe(200); + }); + + it('an auditor reads it via viewAllRecords (VAMA bypass)', async () => { + const r = await stack.apiAs(auditorTok, 'GET', `/data/showcase_private_note/${noteId}`); + expect(r.status, 'view-all bypasses the private baseline').toBe(200); + }); + + it('the auditor still cannot WRITE it (read-only compliance set)', async () => { + const r = await stack.apiAs(auditorTok, 'PATCH', `/data/showcase_private_note/${noteId}`, { + title: 'defaced', + }); + expect(r.status, 'no write bit anywhere on the auditor set').not.toBe(200); + }); + + // ── ADR-0090 D6: the explain engine attributes the VAMA grant ──────────── + it('explain() reports the vama_bypass layer with the auditor set as contributor', async () => { + const security: any = stack.kernel.getService('security'); + expect(security?.explain, 'security service exposes explain()').toBeTruthy(); + const decision = await security.explain( + { object: 'showcase_private_note', operation: 'read', userId: auditorId }, + { isSystem: true }, + ); + expect(decision?.allowed).toBe(true); + const vama = (decision?.layers ?? []).find((l: any) => l.layer === 'vama_bypass'); + expect(vama, 'vama_bypass layer present').toBeTruthy(); + const names = (vama?.contributors ?? []).map((c: any) => c.name); + expect(names, 'the granting set is attributed').toContain('showcase_auditor'); + }); + + // ── ADR-0090 D12: the delegated-admin gate, positive and negative ──────── + it('a delegate assigns an allowlisted position inside their subtree', async () => { + // Bind contributor → showcase_contributor first (admin action; the + // delegate has manageBindings: false and must NOT be able to do this). + const pos = await ql.findOne('sys_position', { where: { name: 'contributor' }, context: SYS }); + const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS }); + expect(pos?.id && ps?.id, 'contributor position + set seeded').toBeTruthy(); + const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { + position_id: pos.id, + permission_set_id: ps.id, + }); + expect(bind.status, 'tenant admin binds set to position').toBeLessThan(300); + + const assign = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { + user_id: targetId, + position: 'contributor', + business_unit_id: 'bu_west_coast', + }); + expect(assign.status, 'in-subtree, allowlisted assignment passes').toBeLessThan(300); + const row = await ql.findOne('sys_user_position', { + where: { user_id: targetId, position: 'contributor' }, + context: SYS, + }); + expect(row, 'assignment persisted').toBeTruthy(); + expect(row.granted_by, 'granted_by audit stamp applied').toBeTruthy(); + }); + + it('the same delegate is refused OUTSIDE their subtree (no lateral reach)', async () => { + const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', { + user_id: targetId, + position: 'contributor', + business_unit_id: 'bu_hq_finance', + }); + expect(r.status, 'HQ Finance is outside the Field Operations subtree').not.toBeLessThan(300); + }); + + it('the delegate cannot hand out a set OFF the allowlist (no self-escalation)', async () => { + // showcase_auditor is not in assignablePermissionSets — direct grant refused. + const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); + const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_permission_set', { + user_id: delegateId, + permission_set_id: ps.id, + }); + expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300); + }); + + it('the delegate cannot re-compose positions (manageBindings: false)', async () => { + const pos = await ql.findOne('sys_position', { where: { name: 'contributor' }, context: SYS }); + const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS }); + const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', { + position_id: pos.id, + permission_set_id: ps.id, + }); + expect(r.status, 'binding writes need manageBindings').not.toBeLessThan(300); + }); + + // ── ADR-0090 D5/D9: the audience-anchor binding gate ───────────────────── + it('binding a high-privilege set to an anchor is rejected even for the admin', async () => { + const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); + expect(everyone, 'built-in everyone position seeded').toBeTruthy(); + const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); + const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { + position_id: everyone.id, + permission_set_id: auditor.id, + }); + expect(r.status, 'viewAllRecords on the everyone anchor is lint-tier blocked at runtime').not.toBeLessThan(300); + }); + + it('binding the guest-safe set to the guest anchor is accepted', async () => { + const guest = await ql.findOne('sys_position', { where: { name: 'guest' }, context: SYS }); + expect(guest, 'built-in guest position seeded').toBeTruthy(); + const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS }); + const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', { + position_id: guest.id, + permission_set_id: ps.id, + }); + expect(r.status, 'read-only + create-intake set passes the guest tier').toBeLessThan(300); + }); +}); diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md index fbf23ad5bd..3c55969d20 100644 --- a/packages/plugins/plugin-security/README.md +++ b/packages/plugins/plugin-security/README.md @@ -9,7 +9,7 @@ `plugin-security` hooks into the ObjectQL pipeline and applies authorization on every read and write: -1. **Resolve permission sets** — match user roles against `SysPermissionSet` metadata. +1. **Resolve permission sets** — expand the user's positions and direct grants against `SysPermissionSet` metadata. 2. **Check object CRUD** — `allowRead`, `allowCreate`, `allowEdit`, `allowDelete`. 3. **Inject RLS** — compile row-level policy expressions into query filters. 4. **Mask fields** — remove non-readable fields from results; flag non-editable fields on writes. @@ -60,10 +60,10 @@ In CLI / dev-server mode the `OS_MULTI_ORG_ENABLED` environment variable (defaul | Export | Kind | Description | |:---|:---|:---| | `SecurityPlugin` | class | Kernel plugin that installs the four-step security chain. | -| `PermissionEvaluator` | class | Evaluates object-level CRUD permissions across roles (most-permissive merge). | +| `PermissionEvaluator` | class | Evaluates object-level CRUD permissions across the held permission sets (most-permissive merge). | | `RLSCompiler` | class | Compiles RLS expressions into ObjectQL filter AST. | | `FieldMasker` | class | Strips non-readable fields and identifies non-editable ones. | -| `SysRole`, `SysPermissionSet` | objects | Metadata objects registered by the plugin. | +| `SysPosition`, `SysPermissionSet` | objects | Metadata objects registered by the plugin. | ## System objects @@ -71,10 +71,10 @@ The plugin contributes these system objects to the kernel: | Object | Purpose | |:---|:---| -| `sys_position` | User role definitions. | -| `sys_permission_set` | Bundles object and field permissions; can include RLS expressions. | +| `sys_position` | Position (岗位) definitions — the flat permission-set distribution layer (ADR-0090 D3). | +| `sys_permission_set` | Bundles object and field permissions; can include RLS expressions and a delegated-admin `admin_scope` (ADR-0090 D12). | -Assignment tables (role ↔ user, role ↔ permission_set) are provided by [`@objectstack/plugin-auth`](../plugin-auth) when used together. +Assignment tables (position ↔ user, position ↔ permission_set, user ↔ permission_set) are registered alongside and governed by the delegated-admin and audience-anchor gates. ## RLS expression language diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 26688c7504..0c25f7e570 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -137,7 +137,7 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Recipient Type', required: true, defaultValue: 'business_unit', - description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `unit_and_subordinates` walks the sys_position.parent hierarchy to also include every subordinate role (ADR-0056 D6).', + description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position\'s holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit\'s members via the sys_business_unit tree (ADR-0057 D5).', group: 'Recipient', }, ), diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 9d7d7dddb4..3181bba9bb 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -164,8 +164,8 @@ export const AgentSchema = lazySchema(() => z.object({ active: z.boolean().default(true), access: z.array(z.string()).optional().describe('Who can chat with this agent'), - /** Permission profiles/roles required to use this agent */ - permissions: z.array(z.string()).optional().describe('Required permissions or roles'), + /** Permission-set capabilities required to use this agent */ + permissions: z.array(z.string()).optional().describe('Required permission-set capabilities'), /** Multi-tenancy & Visibility */ tenantId: z.string().optional().describe('Tenant/Organization ID'), diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 88dd4c277e..adf58e5349 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -95,8 +95,8 @@ export const ToolSchema = lazySchema(() => z.object({ /** Whether the tool requires human confirmation before execution */ requiresConfirmation: z.boolean().default(false).describe('Require user confirmation before execution'), - /** Permission profiles/roles required to use this tool */ - permissions: z.array(z.string()).optional().describe('Required permissions or roles'), + /** Permission-set capabilities required to use this tool */ + permissions: z.array(z.string()).optional().describe('Required permission-set capabilities'), /** Whether the tool is enabled */ active: z.boolean().default(true).describe('Whether the tool is enabled'), diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 7cbc79a62b..3c069d40ab 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -11,8 +11,8 @@ import { z } from 'zod'; * ## Overview * * Row-Level Security (RLS) allows you to control which rows users can access - * in database tables based on their identity and role. Unlike object-level - * permissions (CRUD), RLS provides record-level filtering. + * in database tables based on their identity and positions. Unlike + * object-level permissions (CRUD), RLS provides record-level filtering. * * ## Use Cases * @@ -64,12 +64,13 @@ import { z } from 'zod'; * * ## Salesforce Sharing Rules Comparison * - * Salesforce uses "Sharing Rules" and "Role Hierarchy" for record-level access. + * Salesforce uses "Sharing Rules" and a visibility hierarchy for record-level + * access (our equivalent hierarchy is the business-unit tree, ADR-0090 D3). * ObjectStack RLS provides similar functionality with more flexibility. - * + * * Salesforce: - * - Criteria-Based Sharing: Share records matching criteria with users/roles - * - Owner-Based Sharing: Share records based on owner's role + * - Criteria-Based Sharing: Share records matching criteria with users/groups + * - Owner-Based Sharing: Share records based on who owns them * - Manual Sharing: Individual record sharing * * ObjectStack RLS: @@ -81,7 +82,7 @@ import { z } from 'zod'; * * 1. **Always Define SELECT Policy**: Control what users can view * 2. **Define INSERT/UPDATE CHECK Policies**: Prevent data leakage - * 3. **Use Role-Based Policies**: Apply different rules to different roles + * 3. **Use Position-Scoped Policies**: Apply different rules to different positions * 4. **Test Thoroughly**: RLS can have complex interactions * 5. **Monitor Performance**: Complex RLS policies can impact query performance * @@ -291,7 +292,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ * 2. `field = 'literal'` — equality against a single-quoted string literal * 3. `field IN (current_user.)` — set membership against a * pre-resolved id array (see "Dynamic membership" below) - * 4. `1 = 1` — always true / no restriction (privileged-role allow-all) + * 4. `1 = 1` — always true / no restriction (privileged-position allow-all) * * There is intentionally **no** support for `AND`/`OR`/`NOT`, comparison * operators other than `=`, `IS NULL`/`IS NOT NULL`, `NOT IN`, `LIKE`/ @@ -324,7 +325,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ * @example "owner_id = current_user.id" * @example "status = 'published'" * @example "assigned_to_id IN (current_user.team_member_ids)" // §7.3.1 pre-resolved - * @example "1 = 1" // privileged-role allow-all + * @example "1 = 1" // privileged-position allow-all */ using: z.string() .optional() @@ -571,7 +572,7 @@ export const RLS = { }), /** - * Create a role-based policy + * Create a position-scoped policy */ positionPolicy: (object: string, positions: string[], condition: string): RowLevelSecurityPolicy => ({ name: `${object}_${positions.join('_')}_access`, diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index 2c12386429..63d1346d70 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -66,7 +66,7 @@ const BaseSharingRuleSchema = z.object({ // Recipient (Whom to share with) sharedWith: z.object({ type: ShareRecipientType, - value: z.string().describe('ID or Code of the User/Group/Role'), + value: z.string().describe('ID or Code of the recipient (user / group / position / business unit)'), }).describe('The recipient of the shared access'), }); @@ -88,7 +88,7 @@ export const OwnerSharingRuleSchema = lazySchema(() => BaseSharingRuleSchema.ext ownedBy: z.object({ type: ShareRecipientType, value: z.string(), - }).describe('Source group/role whose records are being shared'), + }).describe('Source group/position whose records are being shared'), })); /** diff --git a/packages/spec/src/security/territory.zod.ts b/packages/spec/src/security/territory.zod.ts index 30e9aa1921..ef36cbb3d7 100644 --- a/packages/spec/src/security/territory.zod.ts +++ b/packages/spec/src/security/territory.zod.ts @@ -5,18 +5,19 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; /** * Territory Management Protocol - * Defines a matrix reporting structure that exists parallel to the Role Hierarchy. - * + * Defines a matrix reporting structure that exists parallel to the + * business-unit hierarchy (ADR-0090 D3 — the org tree is `sys_business_unit`). + * * USE CASE: * - Enterprise Sales Teams (Geo-based: "EMEA", "APAC") * - Industry Verticals (Industry-based: "Healthcare", "Financial") * - Strategic Accounts (Account-based: "Strategic Accounts") - * - * DIFFERENCE FROM ROLE: - * - Role: Hierarchy of PEOPLE (Who reports to whom). Stable. HR-driven. + * + * DIFFERENCE FROM THE BUSINESS-UNIT TREE: + * - Business unit: Hierarchy of PEOPLE (org structure). Stable. HR-driven. * - Territory: Hierarchy of ACCOUNTS/REVENUE (Who owns which market). Flexible. Sales-driven. * - One User can be assigned to MANY Territories (Matrix). - * - One User has only ONE Role (Tree). + * - One User belongs to one primary business unit (Tree). */ import { lazySchema } from '../shared/lazy-schema'; diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index e0e8a61408..9a9739ab1a 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -520,6 +520,61 @@ describe('defineStack', () => { expect(() => defineStack(config, { strict: true })).not.toThrow(); }); + it('should allow permission grants on platform (sys_) objects the runtime provides', () => { + // ADR-0090 D12: a delegated-admin set carries CRUD on the RBAC link + // tables — platform objects contributed by the runtime, not the stack. + const config = { + manifest: baseManifest, + objects: [ + { name: 'crm_lead', fields: { email: { type: 'email' } } }, + ], + permissions: [ + { + name: 'delegate_admin', + objects: { + sys_user_position: { allowRead: true, allowCreate: true }, + sys_business_unit: { allowRead: true }, + }, + adminScope: { businessUnit: 'Field Operations', manageAssignments: true }, + }, + ], + }; + expect(() => defineStack(config, { strict: true })).not.toThrow(); + }); + + it('should allow seed data targeting platform (sys_) objects', () => { + // The seed loader officially supports sys_* targets (isSystem context); + // e.g. an app seeding the ADR-0090 business-unit tree. + const config = { + manifest: baseManifest, + objects: [ + { name: 'crm_lead', fields: { email: { type: 'email' } } }, + ], + data: [ + { + object: 'sys_business_unit', + mode: 'upsert', + externalId: 'id', + records: [{ id: 'bu_root', name: 'HQ', kind: 'company' }], + }, + ], + }; + expect(() => defineStack(config, { strict: true })).not.toThrow(); + }); + + it('should still reject seed data targeting an undefined non-platform object', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'crm_lead', fields: { email: { type: 'email' } } }, + ], + data: [ + { object: 'lead', records: [{ name: 'typo — crm_lead was meant' }] }, + ], + }; + expect(() => defineStack(config, { strict: true })).toThrow("object 'lead'"); + }); + it('should skip cross-reference validation when no objects are defined', () => { const config = { manifest: baseManifest, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9ca5e3ff19..40b49edeaf 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -530,6 +530,19 @@ function validateSingleApp(config: ObjectStackDefinition): string[] { ]; } +/** + * Platform-provided object names (`sys_` / `cloud_` / `ai_` prefixes — the + * same classification the seed loader applies). These objects are contributed + * by the runtime, never by the stack, so cross-reference checks must not + * demand they appear in `config.objects`: an app legitimately seeds the + * ADR-0090 business-unit tree (`sys_business_unit`) or grants a delegated + * administrator CRUD on the RBAC link tables (`sys_user_position`, ADR-0090 + * D12). The typo net stays intact for the stack's OWN objects. + */ +function isPlatformObjectName(name: string): boolean { + return /^(sys_|cloud_|ai_)/.test(name); +} + /** * Collect all object names defined in a stack definition. */ @@ -592,10 +605,15 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } - // Validate seed data → object references + // Validate seed data → object references (platform objects are runtime- + // provided seed targets — see isPlatformObjectName). if (config.data) { for (const dataset of config.data) { - if (dataset.object && !objectNames.has(dataset.object)) { + if ( + dataset.object && + !objectNames.has(dataset.object) && + !isPlatformObjectName(dataset.object) + ) { errors.push( `Seed data references object '${dataset.object}' which is not defined in objects.`, ); @@ -634,12 +652,14 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { // explicit-permission-set path does not — so the grant is simply lost (e.g. a // public Web-to-Lead INSERT is denied for "roles []"). Fail loudly at build // time. (`validateNamespacePrefix`'s doc already assumes this check lives here.) + // Platform objects are legitimate grant targets (e.g. a delegated-admin set + // carrying CRUD on the RBAC link tables, ADR-0090 D12) — skip them here. if (config.permissions) { for (const perm of config.permissions) { const grants = (perm as { objects?: Record }).objects; if (grants && typeof grants === 'object') { for (const objName of Object.keys(grants)) { - if (!objectNames.has(objName)) { + if (!objectNames.has(objName) && !isPlatformObjectName(objName)) { errors.push( `Permission '${(perm as { name?: string }).name ?? '(unnamed)'}' grants on object ` + `'${objName}' which is not defined in objects.`,