Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions content/docs/permissions/access-recipes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
63 changes: 52 additions & 11 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
4 changes: 2 additions & 2 deletions content/docs/permissions/field-level-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<object>.<field>` and use `readable` / `editable`:

Expand Down Expand Up @@ -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)
Loading