diff --git a/content/docs/permissions/access-recipes.mdx b/content/docs/permissions/access-recipes.mdx index a78bcf9962..2d90ee3e87 100644 --- a/content/docs/permissions/access-recipes.mdx +++ b/content/docs/permissions/access-recipes.mdx @@ -37,7 +37,7 @@ definePermissionSet({ }); ``` -- **"My own"** vs **"my team's"** vs **"the org's"** is the RLS *depth* axis (`readScope` / `writeScope`: `own | own_and_reports | unit | unit_and_below | org`, ADR-0057). A manager set uses `unit`; a rep uses `own`. The hierarchy-relative scopes (`own_and_reports` / `unit` / `unit_and_below`) need the enterprise hierarchy resolver and **fail closed to `own`** without it — see [Access depth](/docs/permissions/profiles#access-depth--readscope--writescope-adr-0057-d1). +- **"My own"** vs **"my team's"** vs **"the org's"** is the RLS *depth* axis (`readScope` / `writeScope`: `own | own_and_reports | unit | unit_and_below | org`, ADR-0057). A manager set uses `unit`; a rep uses `own`. The hierarchy-relative scopes (`own_and_reports` / `unit` / `unit_and_below`) need the enterprise hierarchy resolver and **fail closed to `own`** without it — see [Access depth](/docs/permissions/permission-sets#access-depth--readscope--writescope-adr-0057-d1). - Grants combine **most-permissively** across a user's sets; the tenant-isolation policy `AND`s on top; the superuser bypass (`viewAllRecords` / `modifyAllRecords`) short-circuits RLS where the object's posture allows it (ADR-0066). - For genuinely sensitive objects, set `access: { default: 'private' }` so they are **not** covered by blanket wildcard grants — access needs an explicit grant. @@ -62,7 +62,7 @@ Keeping capability, assignment and requirement decoupled means resources stay st ## Runnable example -- Built-in 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` shows owner-scoped RLS + tenant isolation). +- Built-in 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` shows owner-scoped RLS + tenant isolation). - Security objects: [`packages/plugins/plugin-security/src/objects`](https://github.com/objectstack-ai/framework/tree/main/packages/plugins/plugin-security/src/objects). ## Anti-patterns diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 3314abedd1..b3a42037fa 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -28,9 +28,12 @@ Authorization splits into three concerns that stay decoupled: 1. **Capability** — *what can be done* (`manage_users`, `export_data`). Defined by the platform or a package; extended by admins. -2. **Assignment** — *who holds it* — permission sets / roles / user bindings - (`sys_permission_set`, `sys_role`, `sys_user_permission_set`, - `sys_role_permission_set`). Runtime records, maintained by admins in Setup. +2. **Assignment** — *who holds it* — permission sets / positions / user + bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`, + `sys_position_permission_set`, `sys_user_position`). Runtime records, + maintained by admins in Setup — and, since ADR-0090 D12, a **governed** + surface: writing them requires tenant-level administration or a covering + delegated `adminScope`, never just CRUD on the tables. 3. **Requirement** — *what a resource needs* — an object / field / action references a capability as a contract. A resource never bakes in "who", only "what is required". @@ -43,17 +46,22 @@ site — the file you read when behavior surprises you. | # | Gate | What it decides | Enforcement site | Failure direction | |---|---|---|---|---| | 1 | **Anonymous deny** | No identity → HTTP 401 on `/data/*`. **Default-on** (ADR-0056 D2): public data serving requires an explicit `api.requireAuth: false` opt-out, which logs a boot warning. Control plane (`/auth`, `/health`, `/discovery`) is exempt; share-links validate their token then read as SYSTEM. | `packages/rest/src/rest-server.ts` `enforceAuth` (default in `packages/spec/src/api/rest-server.zod.ts`) | fail-closed | -| 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No `guest_portal` profile needed. | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow | +| 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the `guest` position). | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow | | 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 | -| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`), manual record shares, criteria sharing rules (owner-type rules are declared but seed-skipped — [not enforced](/docs/permissions/sharing-rules#owner-based-sharing-rules)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not roles). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | owner-only baseline | +| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1) plus the external dial (`externalSharingModel`, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules are declared but seed-skipped — [not enforced](/docs/permissions/sharing-rules#owner-based-sharing-rules)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only | | 5 | **Row-level security** | CEL predicates (`using` read filter, `check` write post-image) compiled into the query. If **no** applicable policy compiles, the result is a deny-all sentinel (fail-closed); an uncompilable policy alongside compilable ones is excluded from the OR-union **with a logged warning** — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top. | `packages/plugins/plugin-security/src/rls-compiler.ts` + `security-plugin.ts` | fail-closed | | 6 | **Field-level security** | Read mask (strip non-readable fields) + write deny per `fields` rules. Caller queries that **filter, sort, group, or aggregate by** a non-readable field are rejected outright (HTTP 403, `field_predicate_denied`) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like `owner_id`). | `packages/plugins/plugin-security/src/field-masker.ts` + `predicate-guard.ts` | fail-closed on predicates; see posture note below | Two orthogonal identity-layer gates run before all of this: the ADR-0069 **authentication-policy gate** (password expiry / enforced MFA blocks a gated session from protected resources while keeping remediation reachable), and -**anti-escalation** (RBAC tables are read-only for `organization_admin`, so an -org admin cannot self-grant platform admin). +**anti-escalation** — RBAC tables are read-only for `organization_admin`, and +since ADR-0090 D12 every RBAC-table write is additionally checked by the +**delegated-admin gate**: tenant admins pass, delegates are confined to their +`adminScope` (BU subtree + assignable-set allowlist, no self-escalation, +strict containment for scope grants), and everyone else is denied. The +`everyone`/`guest` audience-anchor bindings reject high-privilege sets at the +data layer for every caller. ## Combination semantics (the fixed order) @@ -123,7 +131,7 @@ one of two doors, each writing only what it owns: resolve). - **Environment-admin door** (`metadata-admin`) — the cross-package all-objects matrix plus subject **assignment** (`sys_user_permission_set`, - `sys_role_permission_set`), edited **live** (config). It owns env-authored + `sys_position_permission_set`), edited **live** (config). It owns env-authored sets (`managedBy` `platform`/`user`) and assignments — not package sets. - **Data-layer write gate** — the security middleware **refuses** any admin-door write to a `managedBy:'package'` `sys_permission_set` row, and refuses a @@ -141,14 +149,46 @@ one of two doors, each writing only what it owns: |---|---|---| | 1 · Package development | Zod-validated authoring; roles / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates | | 2 · Distribution / install / upgrade / uninstall | Install-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-defined | ADR-0025/0028/0048/0086 | -| 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_role` etc.); anti-escalation; union semantics | ADR-0057 D4 | +| 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_position` etc.); anti-escalation; union semantics | ADR-0057 D4 | | 4 · Runtime enforcement | The six-gate chain above; ~18 primitives enforced and CI-guarded | ADR-0056 D10 matrix | | 5 · Production / enterprise | ADR-0056 D8 dispositions settled (2026-07): compliance configs, data masking, and the global RLSConfig were **removed** (never enforced); field encryption stays honestly `[EXPERIMENTAL]` (roadmap); agent `visibility` is marked `[EXPERIMENTAL]` pending #1901. Enterprise authentication hardening staged per ADR-0069 | ADR-0049/0056 D8, ADR-0069 | +## Explaining a decision (ADR-0090 D6) + +The `security` kernel service exposes `explain(request, callerContext)` — the +first-class answer to "why can 张三 PATCH 李四's leave_request?". It walks the +SAME code paths the middleware enforces with (shared set resolution, +evaluator, FLS mask, RLS composition — *explained by construction*) and +reports every pipeline layer in order: + +``` +principal → required_permissions → object_crud → fls → owd_baseline + → depth → sharing → vama_bypass → rls +``` + +Each layer carries a verdict (`grants` / `denies` / `narrows` / `widens` / +`neutral` / `not_applicable`), a human explanation, and **contributor +attribution** — which permission set granted, reached via which position / +additive baseline / direct grant. For reads, the decision includes the +composed row filter as the machine artifact. Explaining another user requires +the `manage_users` capability. + ## Governance: how "declared = enforced" is kept true -Two CI mechanisms make the security posture a **checked artifact** rather than -a belief: +Four CI-time mechanisms make the security posture a **checked artifact** +rather than a belief: + +- **Security publish linter** (ADR-0090 D7, `validateSecurityPosture` in + `@objectstack/lint`, gating `os compile`): unset OWD on custom objects, + retired OWD aliases, an external dial wider than internal, `'*'` wildcards + carrying View/Modify All outside the platform admin set, high-privilege + `isDefault` (everyone-suggested) sets, and the reserved word "role" in + security identifiers — every error rule mirrors a runtime gate. +- **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` / + `diffAccessMatrix`): with `access-matrix.json` committed next to the config, + `os compile` fails on any capability drift with semantic lines + (`'crm_admin' gains delete on 'crm_lead'`) until the snapshot is updated via + `--update-access-matrix` — the snapshot's git diff is the review artifact. - **Conformance matrix** (`packages/dogfood/test/authz-conformance.matrix.ts`, ADR-0056 D10): every authorization primitive sits in exactly one honest @@ -212,3 +252,4 @@ The complete, prioritized gap map lives in issue **#2561** (the production | [0069](/adr/0069-enterprise-authentication-hardening) | Enterprise authentication hardening (phased) | | [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata | | [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition | +| 0090 | Permission Model v2: position rename + vocabulary freeze, profile removal, fail-closed OWD default + external dial, audience anchors, principal taxonomy, publish linter, delegated administration, explain engine + access matrix | diff --git a/content/docs/permissions/field-level-security.mdx b/content/docs/permissions/field-level-security.mdx index d87e597a7a..c1a6f791e6 100644 --- a/content/docs/permissions/field-level-security.mdx +++ b/content/docs/permissions/field-level-security.mdx @@ -7,7 +7,7 @@ description: "Control visibility and editability of specific fields — readable Control visibility and editability of specific fields. -## Field Permissions in Profiles +## Field Permissions in Permission Sets Field permissions are keyed `.` and use `readable` / `editable`: @@ -99,5 +99,5 @@ writes. ## See also - [Permissions & Identity overview](/docs/permissions) -- [Profiles](/docs/permissions/profiles) +- [Permission Sets](/docs/permissions/permission-sets) - [Permission Sets](/docs/permissions/permission-sets) diff --git a/content/docs/permissions/index.mdx b/content/docs/permissions/index.mdx index f75f9121bd..67a20cc3df 100644 --- a/content/docs/permissions/index.mdx +++ b/content/docs/permissions/index.mdx @@ -9,15 +9,20 @@ This module covers authentication, authorization, and record- and field-level access control. It is a cross-protocol capability: enforced by the ObjectOS runtime, declared as ObjectQL security metadata. -The model has five layers, each with schema **and** runtime support: **profiles** -(one per user) and additive **permission sets** for object/field rights, a **role -hierarchy** for reporting structure, **sharing rules + Organization-Wide Defaults** -for record visibility, **row-level security** (compiled into every query), and -**field-level security** (declared rules masked and enforced server-side; fields +The model has **five concepts, one reading each** (ADR-0090): additive +**permission sets** are the only capability container; flat **positions** +(岗位) distribute sets to people; the **business-unit tree** (plus the manager +chain) is the one hierarchy and decides how deep you see; each object's +**Organization-Wide Default + sharing** set the record baseline (sharing only +widens); **row-level security** is the expert escape hatch that narrows. +**Field-level security** masks and enforces field rules server-side (fields without a declared rule stay visible by default — see the [FLS posture note](/docs/permissions/authorization#combination-semantics-the-fixed-order)). -Object access scopes -go from `own` through `own_and_reports`, `unit`, `unit_and_below`, to `org`. +Object access scopes go from `own` through `own_and_reports`, `unit`, +`unit_and_below`, to `org`. There is no Profile concept and the word "role" +is reserved (D3) — if you knew the v1 model, start at +[Profiles (removed)](/docs/permissions/profiles) and +[Positions](/docs/permissions/positions). Access rules are metadata like everything else — this is a real sharing rule from the CRM example app: @@ -31,7 +36,7 @@ export const HighValueOpportunitySharingRule = defineSharingRule({ object: 'crm_opportunity', condition: 'record.amount > 100000', accessLevel: 'edit', - sharedWith: { type: 'role', value: 'sales_manager' }, + sharedWith: { type: 'position', value: 'sales_manager' }, active: true, }); ``` @@ -39,16 +44,16 @@ export const HighValueOpportunitySharingRule = defineSharingRule({ Because AI agents act through the same permission-aware surface, these rules bound agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as-tools)). -> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope: `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified 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 self-authorize via a declaration-derived `publicFormGrant` (ADR-0056 Option A — see [Public Forms](/docs/ui/forms)). Organization-Wide Defaults (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) and criteria Sharing Rules (with `role_and_subordinates` hierarchy widening) are live and dogfood-proven (ADR-0056); **owner-type sharing rules and `group`/`guest` recipients are declared but not enforced** — the seeder skips them with a warning ([Sharing Rules](/docs/permissions/sharing-rules#owner-based-sharing-rules)); the Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and [Implementation Status](/docs/releases/implementation-status) for the latest matrix. +> **Implementation status — Permission Model v2 (ADR-0090) is live.** REST → ObjectQL propagates a populated `ExecutionContext` (userId, tenantId, positions, permissions, principalKind) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks fire on every authenticated request; authenticated principals implicitly hold the `everyone` position and anonymous principals hold `guest` (D9). The `member_default` baseline is **additive** (D5 — no fallback cliff) and its owner-write policies are domained to `org_member` holders. The default `member_default` set ships the wildcard tenant-isolation RLS (`organization_id == current_user.organization_id`) with the same canonical-name contract as before; SecurityPlugin is the sole authority for tenant isolation, and analytics auto-bridges to `security.getReadFilter`. **Anonymous traffic is denied by default** (ADR-0056 D2), and public forms self-authorize via a declaration-derived `publicFormGrant`. **An unset OWD fails closed to `private`** (D1) and the D7 publish linter makes it a build error. Criteria sharing rules (with `position` / `unit_and_subordinates` recipients) are live and dogfood-proven; **owner-type rules and `group` recipients are declared but not enforced** — the seeder skips them with a warning ([Sharing Rules](/docs/permissions/sharing-rules#owner-based-sharing-rules)). RBAC-table writes are governed by the delegated-admin gate (D12), and the `security` service answers `explain(request)` per evaluation layer (D6). The Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See [Implementation Status](/docs/releases/implementation-status) for the latest matrix. ## What's in this module - [Authentication](/docs/permissions/authentication) - [Social & Enterprise SSO](/docs/permissions/sso) - [Authorization Architecture](/docs/permissions/authorization) -- [Profiles](/docs/permissions/profiles) +- [Profiles (removed)](/docs/permissions/profiles) - [Permission Sets](/docs/permissions/permission-sets) -- [Role Hierarchy](/docs/permissions/roles) +- [Positions](/docs/permissions/positions) - [Sharing Rules](/docs/permissions/sharing-rules) - [Field-Level Security](/docs/permissions/field-level-security) - [Permission Metadata](/docs/permissions/permission-metadata) @@ -97,43 +102,40 @@ ObjectStack implements a multi-layered security model: ## Best Practices -### 1. Profile Design +### 1. Permission Sets ✅ **DO:** -- Create minimal profiles (start restrictive, extend with permission sets) -- Use descriptive names -- Document the purpose of each profile -- Assign one profile per user +- Keep each set a coherent capability ("Advanced Reporting", "HR Self-Service") +- Start restrictive — the model is additive, so grant by adding sets +- Put org-wide defaults on the `everyone` anchor (low-privilege only) +- Remove grants when no longer needed ❌ **DON'T:** -- Create too many profiles -- Give excessive permissions "just in case" -- Mix unrelated permissions in one profile +- Author "subtraction sets" — restriction is done by *not granting* +- Grant `viewAllRecords`/`modifyAllRecords` "just in case" +- Ship a `'*'` wildcard superuser in a package (the linter rejects it) -### 2. Permission Sets +### 2. Positions ✅ **DO:** -- Use for temporary or special access -- Group related permissions -- Name clearly (e.g., "Advanced Reporting", "Bulk Data Access") -- Remove when no longer needed +- Model job functions (岗位), not departments — keep the list short and flat +- Bind capability through sets; anchor assignments to a business unit +- Use delegated `adminScope`s so subsidiaries manage their own assignments ❌ **DON'T:** -- Use as a replacement for profiles -- Grant system-wide permissions unnecessarily +- Recreate a reporting tree on positions — the hierarchy lives on business units +- Use positions as sharing groups — that is what teams are for -### 3. Role Hierarchy +### 3. Business Units & Depth ✅ **DO:** -- Mirror your organizational structure -- Keep hierarchies simple -- Document reporting relationships -- Review regularly +- Mirror your organizational structure on the BU tree +- Widen visibility with `readScope`/`writeScope` depth grants +- Review the tree after reorgs ❌ **DON'T:** - Create overly deep hierarchies -- Mix different org structures -- Grant too much upward access +- Grant `org` depth when `unit_and_below` suffices ### 4. Sharing Rules @@ -168,11 +170,12 @@ ObjectStack implements a multi-layered security model: ### Initial Setup - [ ] Harden authentication (password policy, enforced MFA, lockout, session controls, IP allowlist) — see [Authentication Hardening](/docs/permissions/authentication#enterprise-authentication-hardening-adr-0069) -- [ ] Define user roles and hierarchies -- [ ] Create profiles for each role -- [ ] Set organization-wide defaults +- [ ] Model the business-unit tree and positions (岗位) +- [ ] Author permission sets and bind them to positions +- [ ] Set each object's organization-wide default (unset = private, and a build error) - [ ] Configure field-level security - [ ] Create sharing rules +- [ ] Commit `access-matrix.json` so capability changes become reviewable diffs ### Ongoing Maintenance @@ -204,19 +207,17 @@ const OrganizationDefaults = { contact: 'controlled_by_parent', }; -// 2. Role Hierarchy (flat roles linked by `parent`) -const roles = [ - { name: 'sales_vp', label: 'Sales VP' }, - { name: 'sales_manager', label: 'Sales Manager', parent: 'sales_vp' }, - { name: 'sales_rep', label: 'Sales Rep', parent: 'sales_manager' }, +// 2. Positions (flat — the hierarchy lives on business units) +const positions = [ + { name: 'sales_manager', label: 'Sales Manager' }, + { name: 'sales_rep', label: 'Sales Rep' }, ]; -// 3. Profile (a PermissionSet with isProfile: true) -const SalesRepProfile = { - name: 'sales_rep', - isProfile: true, +// 3. Permission set (bound to the sales_rep position at install/setup) +const SalesRepAccess = { + name: 'sales_rep_access', objects: { - account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, readScope: 'unit' }, opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, }, fields: { @@ -231,7 +232,7 @@ const AccountSharingRule = { object: 'account', // Share high-value accounts with sales reps condition: P`record.annual_revenue >= 1000000`, - sharedWith: { type: 'role', value: 'sales_rep' }, + sharedWith: { type: 'position', value: 'sales_rep' }, accessLevel: 'read', }; diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json index d9eae56249..1813d6ffad 100644 --- a/content/docs/permissions/meta.json +++ b/content/docs/permissions/meta.json @@ -8,7 +8,7 @@ "authorization", "profiles", "permission-sets", - "roles", + "positions", "sharing-rules", "field-level-security", "permission-metadata", diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 41874559c1..2d306623fd 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -13,7 +13,6 @@ A **Permission Set** defines what a user can do within the application. It contr const salesUserPermission = { name: 'sales_user', label: 'Sales User', - isProfile: false, objects: { account: { @@ -61,7 +60,8 @@ const salesUserPermission = { | :--- | :--- | :--- | :--- | | `name` | `string` | ✅ | Machine name (`snake_case`) | | `label` | `string` | optional | Display label | -| `isProfile` | `boolean` | optional | Is this a user profile (vs. permission set) | +| `isDefault` | `boolean` | optional | [ADR-0090 D5] Install-time suggestion to bind this set to the `everyone` position (admin confirms; never auto-bound) | +| `adminScope` | `AdminScope` | optional | [ADR-0090 D12] Delegated-administration scope (BU subtree + assignable-set allowlist) | | `objects` | `Record` | ✅ | Object-level permissions | | `fields` | `Record` | optional | Field-level security, keyed by `'object.field'` | | `systemPermissions` | `string[]` | optional | System-level capabilities | @@ -195,23 +195,24 @@ contextVariables: { ``` The built-in context variables available in RLS `using`/`check` clauses include -`current_user.id`, `current_user.organization_id`, and `current_user.roles` +`current_user.id`, `current_user.organization_id`, and `current_user.positions` (ADR-0068). `roles` is a **string array** — the only canonical role field — so test -membership with CEL: `'org_admin' in current_user.roles` or -`current_user.roles.exists(r, r == 'sales_manager')`. The framework-seeded built-in -role names are `platform_admin`, `org_owner`, `org_admin`, and `org_member`. +membership with CEL: `'org_admin' in current_user.positions` or +`current_user.positions.exists(p, p == 'sales_manager')`. The framework-seeded +built-in position names are `platform_admin`, `org_owner`, `org_admin`, and +`org_member`; `everyone`/`guest` are the implicit audience anchors (ADR-0090 D9). (`current_user.isPlatformAdmin` is a derived, deprecated alias of -`'platform_admin' in current_user.roles`.) +`'platform_admin' in current_user.positions`.) -## Profile vs. Permission Set +## Union semantics — no Profile tier (ADR-0090 D2) -| Aspect | Profile (`isProfile: true`) | Permission Set (`isProfile: false`) | -| :--- | :--- | :--- | -| **Assignment** | One profile per user | Multiple permission sets per user | -| **Purpose** | Baseline access level | Additional capabilities | -| **Example** | `standard_user`, `admin` | `sales_analytics`, `export_data` | - -A user's effective permissions = **Profile permissions** ∪ **All assigned Permission Set permissions**. +There is no Profile concept: permission sets are the only capability +container and a user may hold any number of them. A user's effective +permissions = the **union of every set reached** — via positions, direct +grants, the `everyone` anchor's bindings, and the additive baseline +(`member_default`). Baseline access is authored on the `everyone` anchor (or +suggested by a package via `isDefault: true`); job functions are ordinary +sets bound to [positions](/docs/permissions/positions). ## Complete Example @@ -219,7 +220,6 @@ A user's effective permissions = **Profile permissions** ∪ **All assigned Perm const salesManagerPermission = { name: 'sales_manager', label: 'Sales Manager', - isProfile: false, objects: { account: { diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 266521b290..6fb4c3aa9e 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -1,132 +1,186 @@ --- title: "Permission Sets" -description: "Extend profile permissions without changing the profile — custom sets, the four built-in permission sets, auto-granting for org admins, and how grants are assigned as data rows." +description: "The only capability container — object CRUD + FLS + scope depth + capabilities, union-merged across everything a user holds. Covers the built-in sets, assignment tables, access depth, the isDefault suggestion, and delegated-admin scopes." --- # Permission Sets -Permission sets extend profile permissions without changing the profile. +A **permission set** is the *only* capability container in the platform +(ADR-0090): a named bundle of object CRUD grants, field-level security, scope +depth, tab visibility, system capabilities, and (optionally) RLS policies. +A user's effective permissions are the **union** of every set they reach — +via their [positions](/docs/permissions/positions), via direct grants, via +the `everyone` anchor, and via the additive baseline. Any set that allows, +wins; restriction is done by *not granting*. ```typescript import { definePermissionSet } from '@objectstack/spec/security'; -export const AdvancedReportingPermissionSet = definePermissionSet({ - name: 'advanced_reporting', - label: 'Advanced Reporting', +export const SalesUser = definePermissionSet({ + name: 'sales_user', + label: 'Sales User', + // Object-level permissions: -> permissions objects: { - opportunity: { - viewAllRecords: true, // Override profile restriction - }, account: { - viewAllRecords: true, + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: false, + readScope: 'unit', // see "Access depth" below }, + opportunity: { allowCreate: true, allowRead: true, allowEdit: true }, }, + // Field-level security: . -> permissions fields: { - 'opportunity.amount': { readable: true }, - 'opportunity.probability': { readable: true }, + 'account.annual_revenue': { readable: true, editable: false }, }, - // System permissions are a flat array of capability strings - systemPermissions: ['run_reports', 'export_reports', 'create_dashboards'], -}); - -export const BulkDataPermissionSet = definePermissionSet({ - name: 'bulk_data_access', - label: 'Bulk Data Access', + // Tab/app visibility + tabPermissions: { app_crm: 'default_on', app_admin: 'hidden' }, - objects: {}, - systemPermissions: ['bulk_api', 'view_all_data'], + // System capabilities (flat strings, resolved against sys_capability) + systemPermissions: ['export_reports'], }); ``` -## Built-in Permission Sets +## Object permission bits + +| Permission | Description | +|------------|-------------| +| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | CRUD on records the user can see | +| `allowTransfer` / `allowRestore` / `allowPurge` | Lifecycle class (RBAC-gated ahead of the M2 operations) | +| `viewAllRecords` | Read ALL records regardless of ownership (super-user read) | +| `modifyAllRecords` | Edit ALL records regardless of ownership (super-user write) | + +## Access depth — `readScope` / `writeScope` (ADR-0057 D1) + +An owner-scoped grant can widen the owner-match declaratively — the "see my +own / my reports / my unit / my unit and below / the whole org" axis: + +| Scope | Owner-match widens to | +|------------|-------------| +| `own` | the caller (baseline; unset = this) | +| `own_and_reports` | the caller + their `sys_user.manager_id` report chain | +| `unit` | owners in the caller's business unit | +| `unit_and_below` | the caller's BU + all descendant BUs | +| `org` | the whole tenant (≈ `viewAllRecords` / `modifyAllRecords`) | + +It resolves to an `owner_id IN (…)` set at request time (ADR-0055 — no +subqueries, no recursion) and composes with the object's +[OWD baseline](/docs/permissions/sharing-rules); sharing rules still widen on +top. When a user's position assignments carry a +[BU anchor](/docs/permissions/positions#assignments--and-the-business-unit-anchor), +the anchor decides *which* unit `unit*` means. -The framework ships four canonical permission sets that are auto-seeded on -boot by `plugin-security` (see -`packages/plugins/plugin-security/src/objects/default-permission-sets.ts`). Use -these as the baseline for any new org — tier custom sets on top rather -than redefining the basics. +> **Open-core boundary (ADR-0016).** `own` and `org` are open-source. The +> hierarchy-relative scopes (`own_and_reports` / `unit` / `unit_and_below`) +> require the enterprise hierarchy resolver; without it they **fail closed to +> `own`** (never fail-open). + +## Capabilities & required permissions (ADR-0066) + +- **Capabilities** are first-class `sys_capability` records — named privileges + such as `manage_users`, `manage_metadata`, `setup.access`, `studio.access`. + Grant them via `systemPermissions`; declare them as prerequisites via an + object/field/app `requiredPermissions` (an **AND**-gate checked before any + CRUD grant). +- The authoring lint `validateCapabilityReferences` warns when + `requiredPermissions` names a capability registered nowhere. + +## Built-in permission sets + +Auto-seeded on boot by `plugin-security` +(`objects/default-permission-sets.ts`): | Name | Scope | What it unlocks | Notes | |:--|:--|:--|:--| -| `admin_full_access` | Platform (no RLS) | All objects, all system permissions, Studio + Setup, cross-org reads | Reserved for platform operators. Auto-granted to the bootstrap admin (`bootstrapPlatformAdmin`) — the **first registered human**. The non-loginable seed-data identity `usr_system` (role `system`, provisioned before the first sign-up to own seeded rows) is deliberately skipped, so the real admin wins the promotion. | -| `organization_admin` | Per-org (`tenant_isolation` RLS, only active when `@objectstack/plugin-org-scoping` is loaded) | Wildcard CRUD inside the org, `manage_org_users`, Setup app shell (only org-scoped entries visible), invite members, manage roles assignment | **Read-only** on `sys_role`, `sys_permission_set`, `sys_role_permission_set`, `sys_user_permission_set`, `sys_user_role` to prevent self-elevation. Does **not** see Studio. | -| `member_default` | Per-org | Standard end-user CRUD on org records; **writes are owner-scoped** | Default profile for invited members. | -| `viewer_readonly` | Per-org | Read access only | For auditors / read-only stakeholders. | - -> **Owner-scoped writes.** `member_default` grants read/create on org records but -> restricts **edit and delete to the records the user created** — via the -> `owner_only_writes` / `owner_only_deletes` RLS policies, keyed on `created_by` -> (the ownership column the engine stamps on every row). These are enforced on -> single-id `update`/`delete` by a **pre-image authorization check**: before the -> mutation the middleware re-reads the target row through the write-operation RLS -> filter, and a member attempting to modify another user's record gets `403`. -> A permission set with `modifyAllRecords` (or no RLS, e.g. `admin_full_access`) -> bypasses this. An object that opts out of audit fields (`systemFields.audit: -> false`, no `created_by`) fails **closed** for member writes — grant -> `modifyAllRecords` or a per-object policy, or model transferable ownership with -> a dedicated owner field + custom policy. - -> **Single-org vs. multi-org runtimes.** The wildcard `tenant_isolation` -> RLS row above only takes effect when `@objectstack/plugin-org-scoping` -> is registered (typically via `OS_MULTI_ORG_ENABLED=true`). In single-org -> mode `plugin-security` detects the missing `org-scoping` service and -> strips the wildcard policy so reads / writes aren't accidentally -> filtered by an unset `current_user.organization_id`. See the -> [Org-Scoping Plugin README](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-org-scoping/README.md) -> for boot order and per-org seed-replay details. - -### Auto-grant for organization admins - -Any user whose `sys_member.role` is `owner`, `admin`, or comma-set -`"owner,admin"` automatically receives an org-scoped grant of -`organization_admin` (one `sys_user_permission_set` row per `(user, org)` -pair). The grant is reconciled by a lifecycle hook in `plugin-security` -on every `sys_member` insert / update / delete, and a `kernel:ready` -backfill sweep cleans up orphans on boot. Demoting a member to `member` -revokes the grant in the same transaction; you do not manage these rows -by hand. - -### Setup app gating - -Setup nav items and the corresponding global settings manifests -(`ai`, `knowledge`, `mail`, `storage`) require the new -`manage_platform_settings` system permission, which is granted **only** to -`admin_full_access`. `organization_admin` sees the Setup shell — -Apps, People & Organization, Roles (read-only) — but not platform-wide -configuration. Tenant-scoped settings (`branding`, `feature_flags`) -remain gated by `setup.access` so org admins can manage them. - -## Assigning Permission Sets - -Permission-set grants are plain data rows, not helper-function calls. Assign a -set to a user by inserting a `sys_user_permission_set` row, or to a role by -inserting a `sys_role_permission_set` row: +| `admin_full_access` | Platform (no RLS) | All objects, all system permissions, Studio + Setup | The ADR-0066 superuser wildcard — the only legitimate `'*'` + View/Modify All combination (the D7 linter rejects it in authored packages) | +| `organization_admin` | Per-org (tenant-isolation RLS) | Wildcard CRUD inside the org, `manage_org_users`, Setup shell | **Read-only** on the RBAC tables (anti-escalation); does not see Studio | +| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) | +| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders | + +## Assigning permission sets + +Grants are plain data rows: ```typescript -// Grant a permission set to a single user +// Grant a set to a single user (direct grant) await objectql.object('sys_user_permission_set').insert({ user_id: 'user123', - permission_set: 'advanced_reporting', + permission_set_id: psId, }); -// Grant a permission set to everyone in a role -await objectql.object('sys_role_permission_set').insert({ - role: 'data_analysts', - permission_set: 'bulk_data_access', +// Bind a set to a position (everyone holding it gets the set) +await objectql.object('sys_position_permission_set').insert({ + position_id: positionId, + permission_set_id: psId, }); ``` -Org-admin grants are reconciled automatically by a `plugin-security` lifecycle -hook (see [Auto-grant for organization admins](#auto-grant-for-organization-admins)) -— you do not insert those rows by hand. +These writes are **governed** (ADR-0090 D12): tenant-level admins pass +through to the ordinary checks; a delegated admin needs a covering +`adminScope`; plain CRUD on the tables grants nothing by itself. Bindings to +the `everyone`/`guest` anchors additionally reject high-privilege sets at the +data layer for *every* caller. + +Org-admin grants (`sys_member.role` owner/admin → `organization_admin`) are +reconciled automatically by a lifecycle hook — you never insert those rows by +hand. + +## Package suggestion — `isDefault` (ADR-0090 D5) + +A package may mark one of its sets `isDefault: true`: an **install-time +suggestion** to bind it to the `everyone` anchor. The admin confirms each +suggestion individually; nothing auto-binds, and the D7 linter rejects an +`isDefault` set that carries anchor-forbidden bits (VAMA, destructive bits, +wildcards, system permissions). + +## Delegated administration — `adminScope` (ADR-0090 D12) + +A permission set may carry an `adminScope`, making its holders **scoped +administrators**: + +```typescript +export const EastSubsidiaryAdmin = definePermissionSet({ + name: 'east_subsidiary_admin', + label: '华东子公司管理员', + // The scope authorizes WHAT may be administered… + adminScope: { + businessUnit: 'east', // WHERE: this BU subtree + manageAssignments: true, // user ↔ position rows + manageBindings: true, // position ↔ set rows + assignablePermissionSets: ['sales_user', 'support_user'], // WHICH sets + }, + // …and the CRUD bits let the requests through at all: + objects: { + sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + sys_position_permission_set: { allowRead: true, allowCreate: true, allowDelete: true }, + }, +}); +``` + +The runtime gate enforces: assignments anchored inside the subtree only, +allowlisted sets only (**to others and to themselves** — no self-escalation), +single-row writes, `granted_by` audit stamping, and **strict containment** +when granting or authoring a set that itself carries an `adminScope` (handing +out your own exact scope is refused). Anchors and security publishes stay +tenant-level. + +## Provenance — package vs environment sets (ADR-0086) + +A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded +idempotently at boot and re-seeded on upgrade; environment-authored sets +(`platform`/`user`) are never clobbered. The data layer refuses admin-door +writes to package rows (two-doors separation), which is what makes package +uninstall well-defined. --- ## See also - [Permissions & Identity overview](/docs/permissions) -- [Profiles](/docs/permissions/profiles) +- [Positions](/docs/permissions/positions) - [Security Permissions Matrix](/docs/permissions/permissions-matrix) +- [Authorization Architecture](/docs/permissions/authorization) diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index c1ba4103ce..5d8923905d 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -1,14 +1,14 @@ --- title: Security Permissions Matrix -description: Visual reference for ObjectStack's security model — permission types, object/profile matrix, field-level security, sharing rules, and role hierarchy +description: Visual reference for ObjectStack's security model — permission types, the object × permission-set matrix, field-level security, sharing rules, business-unit depth, and the access-matrix snapshot gate --- # Security Permissions Matrix -This page provides a comprehensive visual reference for ObjectStack's security model — from object-level permissions to field-level security, sharing rules, and role hierarchies. +This page provides a comprehensive visual reference for ObjectStack's security model — from object-level permissions to field-level security, sharing rules, and business-unit depth. -**Security Model:** ObjectStack uses a layered security model inspired by Salesforce. Permissions are evaluated in order: Object Permission → Organization-Wide Defaults → Record Ownership / Scope Depth → Sharing (rules + manual shares) → Row-Level Security → Field-Level Security. Unlike Salesforce, the role hierarchy is **not** an automatic layer — it widens access only where a sharing rule's `role_and_subordinates` recipient or a scope-depth grant explicitly invokes it. +**Security Model:** ObjectStack uses a layered security model inspired by Salesforce. Permissions are evaluated in order: Object Permission → Organization-Wide Defaults → Record Ownership / Scope Depth → Sharing (rules + manual shares) → Row-Level Security → Field-Level Security. Unlike Salesforce, there is no automatic hierarchy layer — the business-unit tree widens access only where a sharing rule's `unit_and_subordinates` recipient or a scope-depth grant explicitly invokes it (positions are flat, ADR-0090 D3). --- @@ -41,7 +41,7 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a ## 2. Object × Permission-Set Matrix -A profile (a `PermissionSet` with `isProfile: true`) is the primary functional definition of a user; add-on permission sets layer extra capabilities on top. This matrix shows which object permissions each set receives. Use it as a template for your own configuration. +Permission sets are the only capability container (there is no Profile concept — ADR-0090 D2); a user's grants are the union of every set reached via positions, direct grants, the `everyone` anchor, and the additive baseline. This matrix shows which object permissions each set receives. Use it as a template for your own configuration. ### Example: CRM Application @@ -63,9 +63,8 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ permissions: [ { - name: 'sales_rep', + name: 'sales_rep_access', label: 'Sales Representative', - isProfile: true, objects: { account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, @@ -74,9 +73,8 @@ export default defineStack({ }, }, { - name: 'sales_manager', + name: 'sales_manager_access', label: 'Sales Manager', - isProfile: true, objects: { account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true }, contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true }, @@ -92,7 +90,7 @@ export default defineStack({ ## 3. Field-Level Security (FLS) -Field-level security controls visibility and editability of individual fields per profile. +Field-level security controls visibility and editability of individual fields per permission set. | Field | System Admin | Sales Manager | Sales Rep | Marketing | Read Only | |:---|:---|:---|:---|:---|:---| @@ -115,8 +113,7 @@ Field-level security controls visibility and editability of individual fields pe ```typescript { - name: 'sales_rep', - isProfile: true, + name: 'sales_rep_access', objects: { /* ... */ }, // Field-level security — keys are `.` fields: { @@ -131,11 +128,11 @@ Field-level security controls visibility and editability of individual fields pe ## 4. Sharing Rule Types -Sharing rules extend access beyond ownership and role hierarchy. The declarative `SharingRule` schema is a discriminated union with two `type` values — `owner` and `criteria`. Manual and territory sharing are separate mechanisms (see notes below the table). +Sharing rules extend access beyond ownership and the depth axis. The declarative `SharingRule` schema is a discriminated union with two `type` values — `owner` and `criteria`. Manual and territory sharing are separate mechanisms (see notes below the table). | Mechanism | `type` | Description | Example | |:---|:---|:---|:---| -| **Owner-Based** | `owner` | Share records owned by a specific group/role with another recipient — **[experimental — not enforced]**: owner rules are skipped at seed time and materialize no shares yet | All accounts owned by "West Region" team are shared with "Sales Directors" | +| **Owner-Based** | `owner` | Share records owned by a specific group/position with another recipient — **[experimental — not enforced]**: owner rules are skipped at seed time and materialize no shares yet | All accounts owned by "West Region" team are shared with "Sales Directors" | | **Criteria-Based** | `criteria` | Share records matching a CEL predicate over field values | All opportunities where `record.amount > 100000` are shared with "VP Sales" | @@ -143,7 +140,7 @@ Sharing rules extend access beyond ownership and role hierarchy. The declarative -**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Territories** are a separate matrix model (`TerritorySchema` in `packages/spec/src/security/territory.zod.ts`) with their own account/opportunity/case access levels, parallel to the role hierarchy. Owner/criteria rules are re-evaluated on insert/update via internal sharing rule hooks. +**Beyond declarative rules:** Two other sharing mechanisms exist but are **not** `SharingRule` types. **Manual sharing** is a runtime grant — `sys_record_share` rows created with `source: 'manual'` (see `packages/plugins/plugin-sharing/src/sharing-service.ts`). **Territories** are a separate matrix model (`TerritorySchema` in `packages/spec/src/security/territory.zod.ts`) with their own account/opportunity/case access levels, parallel to the business-unit model. Owner/criteria rules are re-evaluated on insert/update via internal sharing rule hooks. ### Configuration Example @@ -157,16 +154,16 @@ Sharing rules extend access beyond ownership and role hierarchy. The declarative type: 'criteria', // condition is a CEL predicate over the record condition: 'record.amount > 100000', - sharedWith: { type: 'role', value: 'vp_sales' }, + sharedWith: { type: 'position', value: 'vp_sales' }, accessLevel: 'read', }, { name: 'west_accounts_to_directors', object: 'account', type: 'owner', - // records owned by this group/role are the source set - ownedBy: { type: 'role', value: 'west_region_rep' }, - sharedWith: { type: 'role', value: 'sales_director' }, + // records owned by this group/position are the source set + ownedBy: { type: 'position', value: 'west_region_rep' }, + sharedWith: { type: 'position', value: 'sales_director' }, accessLevel: 'edit', }, ], @@ -206,91 +203,67 @@ defineStack({ ``` -**Opening Up Access:** OWD restricts the baseline. Sharing rules, role hierarchy, and manual sharing can only **open up** access — never restrict it further than the OWD. +**Opening Up Access:** OWD restricts the baseline. Sharing rules, scope-depth grants, and manual sharing can only **open up** access — never restrict it further than the OWD. --- -## 6. Role Hierarchy +## 6. Business-Unit Hierarchy & Positions -The role hierarchy is a reporting structure that sharing rules can reference to -widen record access. It grants **nothing automatically**: a user's position -above another role does not by itself expose that role's records (ADR-0057 D5 — -the visibility hierarchy does not live on roles). +Positions (岗位) are **flat** distribution groups — they have no parent links +and form no tree (ADR-0090 D3). The one hierarchy is the **business-unit +tree**, and it grants **nothing automatically**: sitting above another unit +does not by itself expose its records. Upward visibility is always an +explicit, per-grant opt-in. ```mermaid graph TD - CEO[CEO] - VP_SALES[VP Sales] - VP_MKT[VP Marketing] - VP_ENG[VP Engineering] - DIR_EAST[Director - East] - DIR_WEST[Director - West] - MKT_MGR[Marketing Manager] - ENG_MGR[Engineering Manager] - REP_1[Sales Rep - East 1] - REP_2[Sales Rep - East 2] - REP_3[Sales Rep - West 1] - REP_4[Sales Rep - West 2] - MKT_SPEC[Marketing Specialist] - DEV_1[Developer 1] - DEV_2[Developer 2] - - CEO --> VP_SALES - CEO --> VP_MKT - CEO --> VP_ENG - - VP_SALES --> DIR_EAST - VP_SALES --> DIR_WEST - - VP_MKT --> MKT_MGR - VP_ENG --> ENG_MGR - - DIR_EAST --> REP_1 - DIR_EAST --> REP_2 - DIR_WEST --> REP_3 - DIR_WEST --> REP_4 - - MKT_MGR --> MKT_SPEC - ENG_MGR --> DEV_1 - ENG_MGR --> DEV_2 - - style CEO fill:#4f46e5,color:#fff - style VP_SALES fill:#7c3aed,color:#fff - style VP_MKT fill:#7c3aed,color:#fff - style VP_ENG fill:#7c3aed,color:#fff - style DIR_EAST fill:#0284c7,color:#fff - style DIR_WEST fill:#0284c7,color:#fff - style MKT_MGR fill:#0284c7,color:#fff - style ENG_MGR fill:#0284c7,color:#fff + HQ[HQ] + EAST[BU - East] + WEST[BU - West] + MKT[BU - Marketing] + EAST_SALES[BU - East Sales] + EAST_SVC[BU - East Service] + + HQ --> EAST + HQ --> WEST + HQ --> MKT + EAST --> EAST_SALES + EAST --> EAST_SVC + + style HQ fill:#4f46e5,color:#fff + style EAST fill:#7c3aed,color:#fff + style WEST fill:#7c3aed,color:#fff + style MKT fill:#7c3aed,color:#fff ``` -**How it is consumed:** the `parent` graph powers the `role_and_subordinates` -sharing-rule recipient — one rule can grant access to a role *and every role -beneath it* (see [Recipient types](/docs/permissions/sharing-rules#recipient-types)). -Manager-over-subordinate record visibility is built explicitly, per grant: +**How the tree is consumed** — three explicit mechanisms: -- **VP Sales sees Directors' and Reps' records** — author a criteria sharing - rule with `sharedWith: { type: 'role', value: 'vp_sales' }`, or grant the VP's - permission set `readScope: 'unit_and_below'` (scope depth, ADR-0057). -- **Cascade one rule down a branch** — share with - `{ type: 'role_and_subordinates', value: 'director_east' }` to reach the - director and everyone below that role. -- **Sales Rep** sees only their own records (plus whatever sharing rules and - manual shares grant) — this part needs no configuration under a `private` OWD. +- **Scope depth** — grant a permission set `readScope: 'unit'` / + `'unit_and_below'`; the owner-match widens to the caller's unit (or its + subtree). A position assignment's `business_unit_id` anchor decides *which* + unit that means for multi-unit users (ADR-0090 Addendum). +- **Sharing-rule recipients** — share with + `{ type: 'unit_and_subordinates', value: 'east' }` to reach everyone in the + East subtree, or `{ type: 'position', value: 'sales_manager' }` to reach a + job function across units. +- **Delegated administration** — an `adminScope` bounds a subsidiary admin to + a BU subtree (ADR-0090 D12). + +A Sales Rep sees only their own records (plus shares) under a `private` OWD — +that part needs no configuration. ### Configuration Example ```typescript defineStack({ - roles: [ - { name: 'ceo', label: 'CEO' }, // root role: omit `parent` - { name: 'vp_sales', label: 'VP Sales', parent: 'ceo' }, - { name: 'vp_marketing', label: 'VP Marketing', parent: 'ceo' }, - { name: 'director_east', label: 'Director - East', parent: 'vp_sales' }, - { name: 'director_west', label: 'Director - West', parent: 'vp_sales' }, - { name: 'sales_rep', label: 'Sales Representative', parent: 'director_east' }, + positions: [ + // Flat job functions — no parent links. + { name: 'vp_sales', label: 'VP Sales' }, + { name: 'sales_director', label: 'Sales Director' }, + { name: 'sales_rep', label: 'Sales Representative' }, ], + // The tree lives on business units (seeded as sys_business_unit rows). }); ``` @@ -323,7 +296,7 @@ flowchart TD | Step | Layer | What It Checks | |:---|:---|:---| -| 1 | **Object Permission** | Does the profile grant any access to this object? | +| 1 | **Object Permission** | Does any resolved permission set grant this operation on the object? | | 2 | **OWD** | Is the object public? If so, grant access immediately | | 3 | **Record Ownership** | Does the user own this record? | | 4 | **Scope Depth** | Does a `readScope` / `writeScope` grant (`own_and_reports` / `unit` / `unit_and_below` / `org`) widen the owner-match to cover the record's owner? | diff --git a/content/docs/permissions/positions.mdx b/content/docs/permissions/positions.mdx new file mode 100644 index 0000000000..102537787d --- /dev/null +++ b/content/docs/permissions/positions.mdx @@ -0,0 +1,119 @@ +--- +title: "Positions" +description: "Positions (岗位) are flat capability-distribution groups — users hold positions, positions bind permission sets. The visibility hierarchy lives on business units, never here. Includes the built-in identity positions and the everyone/guest audience anchors." +--- + +# Positions + +A **position** (岗位) is a named, assignable distribution group: users hold +positions (`sys_user_position` rows), positions bind permission sets +(`sys_position_permission_set` rows), and a user's capability is the union of +every set reached that way plus direct grants. Positions carry **no capability +of their own** — they only decide *who gets which sets*. + +> **Formerly "roles".** ADR-0090 D3 renamed the concept in one step and +> reserved the word *role* platform-wide (the D7 publish linter rejects it in +> security identifiers and labels). The vocabulary is now unambiguous: +> **permission set** = capability, **position** = distribution, +> **business unit** = hierarchy. The single exception is better-auth's +> internal `sys_member.role` (org-membership tier: owner/admin/member). + +```typescript +import type { Position } from '@objectstack/spec/identity'; + +export const positions: Position[] = [ + { name: 'sales_manager', label: 'Sales Manager' }, + { name: 'sales_rep', label: 'Sales Representative' }, + { name: 'service_agent', label: 'Service Agent' }, +]; +``` + +## Positions are flat — the hierarchy lives on business units + +Positions deliberately have **no `parent` link and form no tree** (ADR-0090 +D3, finalizing ADR-0057 D5). Re-adding a reporting tree here is the +position-per-department explosion every mature RBAC deployment regrets. +Visibility depth comes from two dedicated structures: + +- the **business-unit tree** (`sys_business_unit`) + membership + (`sys_business_unit_member`) — consumed by `readScope`/`writeScope` depth + grants (`unit`, `unit_and_below`) and by `unit_and_subordinates` + sharing-rule recipients; +- the **manager chain** (`sys_user.manager_id`) — consumed by + `own_and_reports`. + +**There is no implicit "managers see subordinates' records" grant.** Upward +visibility is always an explicit opt-in: a sharing rule targeting a position +or BU subtree, or a scope-depth grant on the permission set. See +[Sharing Rules](/docs/permissions/sharing-rules). + +## Assignments — and the business-unit anchor + +Assigning a position is a plain data row: + +```typescript +await objectql.object('sys_user_position').insert({ + user_id: 'user123', + position: 'sales_manager', // machine name (sys_position.name) + business_unit_id: 'bu_east', // ADR-0090 Addendum: the assignment anchor +}); +``` + +`business_unit_id` is the **assignment-level BU anchor** with exactly three +consumers: it anchors *where* the assignment's `readScope`/`writeScope` depth +applies (a 华东 sales manager gets manager depth in 华东 only), it is the +delegated-administration boundary ("assignments you create must target your +subtree" — see below), and it is the audit fact ("manager *of what*"). +Capability bits are never BU-scoped — positions never bind to a business unit +at the definition level. + +Writes to `sys_user_position` are **governed** (ADR-0090 D12): tenant-level +admins pass; a delegated admin needs an `adminScope` covering the assignment +(subtree + allowlisted sets, `granted_by` auto-stamped); anyone else — +including holders of plain CRUD on the table — is denied. + +## Built-in identity positions (ADR-0068) + +`current_user.positions` is a string array — the only canonical field (there +is no singular `current_user.position` at runtime). Framework-seeded names: + +| Position | Meaning | +|:---|:---| +| `platform_admin` | Platform operator (SaaS admin) — not a tenant position | +| `org_owner` | Organization owner (`sys_member.role = owner`) | +| `org_admin` | Organization administrator (`sys_member.role = admin`) | +| `org_member` | Organization member (`sys_member.role = member`) | + +Test membership in RLS/CEL with `'org_admin' in current_user.positions`. + +## Audience anchors: `everyone` and `guest` (ADR-0090 D5/D9) + +Two more built-in positions are **implicit** — they are never stored as +assignment rows (the runtime rejects such rows) because whole principal +classes hold them automatically: + +- **`everyone`** — every authenticated user. Binding a permission set to + `everyone` is how an admin authors *default grants* — additive, alongside + the configured baseline (`member_default`); receiving an explicit grant + never costs the baseline (the "fallback cliff" was abolished by D5). + Packages may *suggest* an everyone-binding via a set's `isDefault: true`; + the admin confirms each suggestion — nothing auto-binds. +- **`guest`** — every unauthenticated principal, which holds this position + *and nothing else*. Guest bindings face the strictest tier: explicit + objects only, read-only by default (create is the case-by-case exception, + e.g. public form intake), no View/Modify All, no system permissions. + +Both anchors reject **high-privilege bindings at the data layer** — no View/ +Modify All Data, no delete/purge/transfer, no `'*'` wildcard, no system +permissions — no matter who asks (even a platform admin). Grant powerful sets +through ordinary positions instead. Anchor bindings are tenant-level only: no +delegated admin scope can touch them. + +--- + +## See also + +- [Permissions & Identity overview](/docs/permissions) +- [Permission Sets](/docs/permissions/permission-sets) +- [Sharing Rules](/docs/permissions/sharing-rules) +- Design reference: `docs/design/permission-model.md` · Decision record: ADR-0090 diff --git a/content/docs/permissions/profiles.mdx b/content/docs/permissions/profiles.mdx index 23398c5c63..b0cc68d4a9 100644 --- a/content/docs/permissions/profiles.mdx +++ b/content/docs/permissions/profiles.mdx @@ -1,198 +1,37 @@ --- -title: "Profiles" -description: "Profiles define a user's baseline permissions — object CRUD grants, field-level security, tab visibility, access scopes, capabilities, and built-in identity roles." +title: "Profiles (removed)" +description: "The Profile concept was removed by ADR-0090 D2. Baseline access is now authored with the everyone audience anchor, package isDefault suggestions, and ordinary permission sets distributed via positions." --- -# Profiles +# Profiles — removed (ADR-0090 D2) -Profiles define a user's baseline permissions. +ObjectStack **no longer has a Profile concept**. It was removed pre-launch +(ADR-0090 D2) because it duplicated permission sets while adding a second +mental model: admins had to learn "the ONE primary set" vs "add-on sets", +every user needed a profile picked, and packaged profiles could not compose +across packages without an owner. -## Profile Structure +Everything a profile did is expressed with the remaining vocabulary: -A profile is modelled as a `PermissionSet` with `isProfile: true` (see -`packages/spec/src/security/permission.zod.ts`). There is no distinct -`Profile` type — author it as a `*.profile.ts` metadata entry validated by -`PermissionSetSchema`. - -```typescript -import { definePermissionSet } from '@objectstack/spec/security'; - -export const SalesRepProfile = definePermissionSet({ - name: 'sales_rep', - label: 'Sales Representative', - isProfile: true, - - // Object-level permissions: -> permissions - objects: { - account: { - allowCreate: true, - allowRead: true, - allowEdit: true, - allowDelete: false, - viewAllRecords: false, // See all records regardless of owner - modifyAllRecords: false, // Edit all records regardless of owner - }, - opportunity: { - allowCreate: true, - allowRead: true, - allowEdit: true, - allowDelete: false, - viewAllRecords: false, - modifyAllRecords: false, - }, - }, - - // Field-level security: . -> permissions - fields: { - 'account.annual_revenue': { readable: true, editable: false }, - 'account.description': { readable: true, editable: true }, - }, - - // Tab/app visibility: -> visibility - tabPermissions: { - app_crm: 'default_on', // Shown by default - app_admin: 'hidden', // Not visible - app_sales: 'visible', // Available - }, -}); -``` - -## Object Permission Levels - -| Permission | Description | -|------------|-------------| -| `allowCreate` | Create new records | -| `allowRead` | View records they own or have access to | -| `allowEdit` | Edit records they own or have access to | -| `allowDelete` | Delete records they own or have access to | -| `allowTransfer` | Change record ownership | -| `allowRestore` | Restore soft-deleted records from trash (undelete) | -| `allowPurge` | Permanently delete records (hard delete / GDPR) | -| `viewAllRecords` | View ALL records regardless of ownership | -| `modifyAllRecords` | Edit ALL records regardless of ownership | - -## Access depth — `readScope` / `writeScope` (ADR-0057 D1) - -Beyond the boolean grants above, an owner-scoped object grant can carry a -**`readScope` / `writeScope`** that widens the owner-match *declaratively* — the -ERP "see my own / my reports / my unit / my unit and below / the whole org" axis, -without hand-writing one RLS policy per object: - -| Scope | Owner-match widens to | -|------------|-------------| -| `own` | the caller (baseline; unset = this) | -| `own_and_reports` | the caller + their `sys_user.manager_id` report chain | -| `unit` | owners in the caller's business unit (`sys_business_unit`) | -| `unit_and_below` | the caller's BU + all descendant BUs (BFS) | -| `org` | the whole tenant (≈ `viewAllRecords` / `modifyAllRecords`) | - -```typescript -objects: { - account: { allowRead: true, allowEdit: true, readScope: 'unit_and_below', writeScope: 'own' }, -} -``` - -It resolves to an `owner_id IN (…)` set at request time and AND-injects alongside -RLS (no compiler change; ADR-0055). Sharing rules still widen on top. - -> **Open-core boundary (ADR-0016).** `own` and `org` are open-source. The -> hierarchy-relative scopes (`own_and_reports` / `unit` / `unit_and_below`) require -> the paid `@objectstack/security-enterprise` plugin (BU-subtree + manager-chain -> resolver); without it they **fail closed to `own`** (never fail-open), and -> `defineStack` errors if a grant uses one without `requires: ['hierarchy-security']`. - -## Capabilities & secure-by-default object access (ADR-0066) - -Beyond CRUD grants and scope depth, two newer gates compose with the model above: - -- **Capabilities** are first-class records in the `sys_capability` registry (ADR-0066 D1) — maintainable named privileges such as `manage_metadata`, `manage_users`, `manage_org_users`, `manage_platform_settings`, `setup.access`, and `studio.access`. Grant them through permission sets like any other privilege. -- **Object access posture** — an object may declare `access: { default: 'public' | 'private' }` (ADR-0066 D2). `public` (the default) preserves allow-by-default exposure; `private` makes the object **secure-by-default** (no access unless explicitly granted). -- **Object-level `requiredPermissions`** (ADR-0066 D3) — a string array of capabilities that gate the object as an **AND** alongside CRUD grants (mirrors `App.requiredPermissions`): a user must hold every listed capability *and* the relevant CRUD grant. - -## Built-in identity roles (ADR-0068) - -`current_user.roles` is a **string array** — the only canonical role field (there is no singular `current_user.role`). Four role names are framework-seeded into `sys_role`: - -| Role | Meaning | +| You wanted a profile for… | Author it as… | |:---|:---| -| `platform_admin` | Platform operator (SaaS admin) — not a tenant role | -| `org_owner` | Organization owner (`sys_member.role = owner`) | -| `org_admin` | Organization administrator (`sys_member.role = admin`) | -| `org_member` | Organization member (`sys_member.role = member`) | - -Test membership in RLS/CEL with `'org_admin' in current_user.roles` or `current_user.roles.exists(r, r == 'sales_manager')`. `current_user.isPlatformAdmin` is a derived, deprecated alias of `'platform_admin' in current_user.roles`. - -## Standard Profiles - -### Sales Representative - -```typescript -import { definePermissionSet } from '@objectstack/spec/security'; +| A user's baseline permissions | A permission set bound to the **`everyone`** audience anchor (default grants for all authenticated users), or the configured baseline set (`member_default`) — both **additive** (ADR-0090 D5) | +| A package's default access posture | `isDefault: true` on the package's permission set — an install-time **suggestion** to bind it to `everyone`; the admin confirms, nothing auto-binds | +| A job function's permissions ("Sales Rep") | An ordinary permission set bound to a **[position](/docs/permissions/positions)** (`sales_rep`) via `sys_position_permission_set` | +| Tab/app visibility defaults | `tabPermissions` on any permission set (unchanged) | -export const SalesRepProfile = definePermissionSet({ - name: 'sales_rep', - isProfile: true, - objects: { - lead: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - contact: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - opportunity: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - product: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, - }, - fields: { - 'account.annual_revenue': { readable: true, editable: false }, // Read-only - }, -}); -``` +The former `isProfile` flag was deleted from `PermissionSetSchema`; the +`profile` metadata kind and its plural alias were removed from the metadata +registry. Restriction is done by **not granting** (the model is additive) — +never by authoring "subtraction profiles". -### Sales Manager - -```typescript -import { definePermissionSet } from '@objectstack/spec/security'; - -export const SalesManagerProfile = definePermissionSet({ - name: 'sales_manager', - isProfile: true, - objects: { - lead: { - allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true - }, - account: { - allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true - }, - // ... full access to sales objects - }, -}); -``` - -### Service Agent - -```typescript -import { definePermissionSet } from '@objectstack/spec/security'; - -export const ServiceAgentProfile = definePermissionSet({ - name: 'service_agent', - isProfile: true, - objects: { - case: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, - task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, - account: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, - contact: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, - }, - fields: { - 'case.is_sla_violated': { readable: true, editable: false }, - 'case.resolution_time_hours': { readable: true, editable: false }, - }, -}); -``` - ---- +> **Migrating v1 metadata.** Replace `isProfile: true` with either +> `isDefault: true` (if the set was the app's default posture) or nothing (if +> it was a job-function set — bind it to a position instead). The D7 publish +> linter and the schema both reject the old shape. ## See also -- [Permissions & Identity overview](/docs/permissions) -- [Permission Sets](/docs/permissions/permission-sets) -- [Role Hierarchy](/docs/permissions/roles) +- [Permission Sets](/docs/permissions/permission-sets) — the only capability container +- [Positions](/docs/permissions/positions) — distribution, built-ins, and the `everyone`/`guest` anchors +- Decision record: ADR-0090 (D2, D5, D9) · Design reference: `docs/design/permission-model.md` diff --git a/content/docs/permissions/roles.mdx b/content/docs/permissions/roles.mdx deleted file mode 100644 index 4218de8a50..0000000000 --- a/content/docs/permissions/roles.mdx +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Role Hierarchy" -description: "Roles bundle permissions (via role-based permission-set grants) and form a reporting hierarchy derived from each role's parent link — consumed by role_and_subordinates sharing-rule recipients." ---- - -# Role Hierarchy - -Roles serve two purposes: they are an **assignment target for permission sets** -(grant a set to a role via a `sys_role_permission_set` row — see -[Permission Sets](/docs/permissions/permission-sets#assigning-permission-sets)), -and their `parent` links form a **reporting hierarchy** that sharing rules can -reference to widen record access. - -The hierarchy is derived from the `parent` link on each role — there is no -`RoleHierarchy` container schema. Author roles as individual `*.role.ts` -entries (each matching `RoleSchema`), using `parent` to point at the role they -report to: - -```typescript -import type { Role } from '@objectstack/spec/identity'; - -export const roles: Role[] = [ - // Top level - { name: 'executive', label: 'Executive' }, - - // Sales hierarchy - { name: 'sales_director', label: 'Sales Director', parent: 'executive' }, - { name: 'sales_manager', label: 'Sales Manager', parent: 'sales_director' }, - { name: 'sales_rep', label: 'Sales Representative', parent: 'sales_manager' }, - - // Service hierarchy - { name: 'service_director', label: 'Service Director', parent: 'executive' }, - { name: 'service_manager', label: 'Service Manager', parent: 'service_director' }, - { name: 'service_agent', label: 'Service Agent', parent: 'service_manager' }, -]; -``` - -## How Role Hierarchy Works - -``` - Executive - / \ - Sales Director Service Director - | | - Sales Manager Service Manager - | | - Sales Rep Service Agent -``` - -**There is no implicit "managers see subordinates' records" grant.** Unlike -Salesforce, the role hierarchy by itself does not widen record visibility -(ADR-0057 D5: the visibility hierarchy does not live on roles). Upward -visibility is always an explicit, opt-in grant: - -- **Sharing rules** — a criteria rule whose recipient is - `{ type: 'role', value: 'sales_director' }` or - `{ type: 'role_and_subordinates', value: 'sales_manager' }`. The - `role_and_subordinates` recipient is where the `parent` graph is consumed: - it expands to everyone holding the named role *or any role beneath it*. - See [Sharing Rules](/docs/permissions/sharing-rules#recipient-types). -- **Scope-depth grants** — an owner-scoped object grant with - `readScope: 'own_and_reports'` (manager chain via `sys_user.manager_id`) or - `'unit'` / `'unit_and_below'` (business units). These require the paid - hierarchy resolver and **fail closed to `own`** without it — see - [Access depth](/docs/permissions/profiles#access-depth--readscope--writescope-adr-0057-d1). - -**Example:** to let a Sales Director see records owned by Sales Managers and -Sales Reps, author a criteria sharing rule with the director role as recipient, -or grant the director's permission set `readScope: 'unit_and_below'` — neither -happens automatically. - ---- - -## See also - -- [Permissions & Identity overview](/docs/permissions) -- [Sharing Rules](/docs/permissions/sharing-rules) -- [Profiles](/docs/permissions/profiles) diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index e43a66fa98..6191c95480 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -1,57 +1,72 @@ --- title: "Sharing Rules" -description: "Extend record access beyond the role hierarchy — organization-wide defaults, criteria- and owner-based sharing rules, recipient types, and RLS-safe analytics read scope." +description: "Record-level access: the organization-wide default (OWD) baseline per object, the external sharing dial, criteria sharing rules and recipient types, and the RLS-safe analytics read scope." --- -# Sharing Rules +# Sharing & Organization-Wide Defaults -Sharing rules extend access beyond the role hierarchy. +Record-level access starts from each object's **organization-wide default +(OWD)** and can only be **widened** from there — by scope-depth grants, +manual record shares, criteria sharing rules, and team grants. Row-level +security is the one layer that narrows. ## Organization-Wide Defaults (OWD) -Set baseline access for all users: - -Each object's OWD is a single value from the `OWDModel` enum (`private`, -`public_read`, `public_read_write`, `controlled_by_parent`): +Each object declares its baseline with `sharingModel` — one of exactly four +canonical values (ADR-0090 D4; the legacy aliases `read` / `read_write` / +`full` were removed): ```typescript -export const OrganizationDefaults = { - lead: 'private', // Users see only their own records - account: 'private', - contact: 'controlled_by_parent', // Access controlled by parent (Account) - opportunity: 'private', - campaign: 'public_read', // All users can read - product: 'public_read', -}; +export const LeaveRequest = ObjectSchema.create({ + name: 'leave_request', + sharingModel: 'private', // owner + shares only + // … +}); ``` -## Access Levels - -| Level | Description | +| Value | Description | |-------|-------------| | `private` | Owner only (+ scope-depth grants and record shares) | -| `public_read` | All users can read | +| `public_read` | All users can read; writes stay owner-scoped | | `public_read_write` | All users can read and edit | -| `controlled_by_parent` | Controlled by parent object | +| `controlled_by_parent` | Access follows the master record (master-detail children) | -> **The default is open, not private.** An object that declares **no -> `sharingModel`** — or that has no `owner_id` field for the owner-match to key -> on — gets **no record-level OWD filter at all** (it behaves as public within -> the tenant; only tenant-isolation RLS still applies). If records should be -> owner-scoped, declare `sharingModel: 'private'` explicitly on the object. -> See `effectiveSharingModel` in -> `packages/plugins/plugin-sharing/src/sharing-service.ts`. +> **The default is `private`, fail-closed (ADR-0090 D1).** A custom object +> that declares **no `sharingModel`** — or that stores an unknown value — is +> treated as `private` at runtime: without a C/R/U permission grant *plus* an +> explicit baseline decision, records are owner-visible only. This inverts +> the pre-v2 behavior (unset used to mean org-public), which caused the +> leave_request incident: an object with CRUD grants but no OWD silently +> exposed every employee's records org-wide. The **D7 publish linter** +> additionally makes an unset OWD a *build error* (`security-owd-unset` in +> `os compile`), so the baseline is always an authored decision. + +## The external dial — `externalSharingModel` (ADR-0090 D11) + +Portal/partner scenarios get a second, independent dial with the same enum: + +```typescript +sharingModel: 'public_read', // internal baseline +externalSharingModel: 'private', // external principals: own + shares only +``` + +- Defaults to `private`; **external must never be wider than internal** + (`private < public_read < public_read_write`) — the linter errors on a + wider external dial (`security-external-wider-than-internal`). +- "External" is a property of the **principal** (`audience: 'external'`), + never of the object. The BU depth axis does not apply to externals; their + visibility = own records + explicit shares + the external OWD. ## Criteria-Based Sharing Rules -Share records based on field criteria: +Share records matching a predicate with a recipient: ```typescript import { defineSharingRule } from '@objectstack/spec/security'; export const AccountTeamSharingRule = defineSharingRule({ name: 'account_team_sharing', - label: 'Share Active Customers with Sales Team', + label: 'Share Active Customers with Sales Managers', type: 'criteria', object: 'account', @@ -59,10 +74,7 @@ export const AccountTeamSharingRule = defineSharingRule({ condition: P`record.type == "customer" && record.is_active == true`, // Who to share with (a single recipient — see the recipient types below) - sharedWith: { - type: 'role', - value: 'sales_manager', - }, + sharedWith: { type: 'position', value: 'sales_manager' }, // Access level granted: read | edit | full accessLevel: 'edit', @@ -71,86 +83,44 @@ export const AccountTeamSharingRule = defineSharingRule({ ### Recipient types -`sharedWith` (and `ownedBy`) accept a `{ type, value }` recipient. The supported -`type` values are: +`sharedWith` (and `ownedBy`) accept a `{ type, value }` recipient: | `type` | Shares with | |:--|:--| | `user` | A single user | -| `group` | All members of a public group — **declared but not enforced**: there is no runtime recipient mapping yet, so a rule with a `group` recipient is skipped at seed time with a warning (ADR-0049) | -| `role` | Everyone assigned that role | -| `role_and_subordinates` | Everyone in that role **and every role below it** in the hierarchy (ADR-0056 D6) — configurable per rule, so one rule can cascade down a branch of the org chart | +| `group` | All members of a public group — **declared but not enforced**: skipped at seed time with a warning (ADR-0049) | +| `position` | Everyone assigned that position (flat expansion — positions have no tree) | +| `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) | +| `team` | Members of a `sys_team` — teams *receive* sharing; they never carry capability (ADR-0090 D8) | -```typescript -// Share with a sales manager AND everyone reporting up to them. -sharedWith: { type: 'role_and_subordinates', value: 'sales_manager' }, -``` - -The recipient set is expanded by `@objectstack/plugin-sharing` when the rule is -evaluated (`afterInsert` / `afterUpdate`); `role_and_subordinates` walks the -`sys_role.parent` graph (cycle-safe). - -A criteria `condition` must be compilable by the CEL → filter pushdown compiler. -A condition the compiler cannot lower (functions, cross-object traversal) is -**skipped and logged — never seeded as a permissive match-all** (ADR-0049), so a -bad condition under-shares rather than over-shares. +A criteria `condition` must be compilable by the CEL → filter pushdown +compiler. A condition the compiler cannot lower is **skipped and logged — +never seeded as a permissive match-all** (ADR-0049): a bad condition +under-shares rather than over-shares. ## Owner-Based Sharing Rules -> **[Experimental — not enforced.]** Owner-based (`type: 'owner'`) rules depend -> on live role membership and have no static `criteria_json` equivalent, so the -> seeder **skips them with a warning** — they materialize **no** record shares -> today (see `packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts` -> and the `SharingRuleSchema` notes in `packages/spec/src/security/sharing.zod.ts`). -> Do not rely on an owner-based rule to deliver access; use a criteria rule or a -> scope-depth grant until this ships. - -Share based on record owner characteristics: - -```typescript -import { defineSharingRule } from '@objectstack/spec/security'; - -export const OpportunityOwnerSharingRule = defineSharingRule({ - name: 'opportunity_owner_sharing', - label: 'Share Sales Rep Opportunities with Managers', - type: 'owner', - object: 'opportunity', - - // Share records owned by this group/role - ownedBy: { - type: 'role', - value: 'sales_rep', - }, - - // Share with this recipient - sharedWith: { - type: 'role', - value: 'sales_manager', - }, - - accessLevel: 'read', -}); -``` +> **[Experimental — not enforced.]** Owner-based (`type: 'owner'`) rules +> depend on live position membership and have no static `criteria_json` +> equivalent, so the seeder **skips them with a warning** — they materialize +> no record shares today. Use a criteria rule or a scope-depth grant. ## Analytics and Dataset Read Scope -Dataset-bound dashboards and reports often execute through the analytics -service, including the NativeSQL strategy. That path still has to honor the -same row-level security rules as ObjectQL. `@objectstack/plugin-security` -therefore registers a public `security` service with -`getReadFilter(object, context)`, and `@objectstack/service-analytics` -auto-bridges to it unless an explicit `getReadScope` option is supplied. - -The bridge is context-aware and may be async because permission-set and RLS -resolution can hit the database. The analytics service applies the returned -filter to the base object and to joined objects declared by a dataset. If read -scope resolution fails or an unsupported predicate would have to be translated -into SQL, the analytics query fails closed instead of dropping the filter. +Dataset-bound dashboards execute through the analytics service (including +NativeSQL) and must honor the same row security as ObjectQL. +`@objectstack/plugin-security` registers the `security` service — +`getReadFilter(object, context)` returns the caller's composed row filter and +`@objectstack/service-analytics` auto-bridges to it for the base object and +every join. Resolution failures fail **closed** (zero rows), never +filter-less. The same service also exposes +[`explain(request)`](/docs/permissions/authorization#explaining-a-decision-adr-0090-d6) +— the per-layer answer to "why can this user see that row". --- ## See also - [Permissions & Identity overview](/docs/permissions) -- [Role Hierarchy](/docs/permissions/roles) +- [Positions](/docs/permissions/positions) - [Field-Level Security](/docs/permissions/field-level-security)