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
12 changes: 12 additions & 0 deletions .changeset/setup-nav-positions-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/plugin-security": patch
"@objectstack/platform-objects": patch
---

Setup → Access Control nav: the `sys_position` entry is renamed
`nav_roles`/"Roles" → `nav_positions`/"Positions" (岗位 / ポジション /
Posiciones) — the last "role" leftover in platform UI copy (ADR-0090 D3;
the Studio-side relabel already landed in objectui). The framework's
`.objectui-sha` pin is bumped to pick up the Studio Access-pillar explain
panel ("why can this user access?", ADR-0090 D6) and the suggested
audience-binding install prompt (D5/D9).
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16e2615f0bbe5d461d9381f1ec31bd2e85089c67
397dcad73eaf6fdaa1740bff15f9d8faf45c4232
92 changes: 92 additions & 0 deletions content/docs/permissions/access-matrix.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
title: Access-Matrix Snapshot Gate
description: A committed (permission set × object) capability matrix that fails the build on any grant drift, with the semantic diff as the review artifact (ADR-0090 D6).
---

# Access-Matrix Snapshot Gate

AI drafts permission metadata on this platform; the one thing it must never do
is *silently* change who can do what. The access-matrix gate makes every
capability change a **visible, reviewable build event**: the
(permission set × object) matrix is derived purely from metadata on every
`os compile`, diffed against a committed snapshot, and any drift **fails the
build** with a semantic explanation until a human regenerates the snapshot.

## Opting in

The gate is per-app: commit an `access-matrix.json` next to
`objectstack.config.ts`. Generate the first snapshot with:

```bash
os compile --update-access-matrix
```

No `access-matrix.json` → the gate is skipped (not opted in). Both
`examples/app-crm` and `examples/app-showcase` are opted in.

## What a drift failure looks like

Change a grant — say, giving the showcase baseline set access to the private
notes object — and the next `os compile` fails:

```
→ Checking access-matrix snapshot (ADR-0090 D6)...

✗ Access matrix drift (1 change) — capability changes must be reviewed
• 'showcase_member_default' gains access to 'showcase_private_note' (create, read, edit)
If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.
```

The diff lines are **semantic**, not JSON: gained/lost operations, View/Modify
All changes, depth changes, OWD swings. If the change is intended, regenerate
and commit — the snapshot's git diff then rides the pull request, where a
reviewer sees the capability delta in plain language instead of spelunking
through metadata files.

## The snapshot format

One entry per (permission set × object) grant, sorted for stable diffs
(`AccessMatrixSchema`, version 1):

```json
{
"version": 1,
"entries": [
{
"permissionSet": "showcase_auditor",
"object": "showcase_private_note",
"create": false,
"read": true,
"edit": false,
"delete": false,
"viewAllRecords": true,
"modifyAllRecords": false,
"sharingModel": "private"
}
]
}
```

`read` is also `true` when View/Modify All grants it; `edit`/`delete` are also
`true` under Modify All — the matrix reports **effective** capability, not the
raw flags. Each row carries the object's OWD so a baseline swing
(`private` → `public_read_write`) is a visible diff too.

## How it relates to the rest of the defense

- The **[security posture linter](/docs/permissions/authorization#governance-how-declared--enforced-is-kept-true)**
(ADR-0090 D7) rejects *individually dangerous* declarations (unset OWD,
high-privilege anchor suggestions, retired aliases). The matrix gate catches
what the linter can't: a *legitimate-looking* change whose blast radius a
human should still sign off.
- The **[explain engine](/docs/permissions/explain)** is the same evaluation
logic pointed at one decision at runtime; the matrix is the build-time sweep
across all of them.
- Both are only possible because grants are **structured data** — the standing
argument for the closed authoring vocabulary over freeform predicates.

## See also

- [Authorization Architecture](/docs/permissions/authorization)
- [Explain Engine](/docs/permissions/explain)
- [Security Permissions Matrix](/docs/permissions/permissions-matrix) — the human-readable capability tables
6 changes: 5 additions & 1 deletion content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Two orthogonal identity-layer gates run before all of this: the ADR-0069
session from protected resources while keeping remediation reachable), and
**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
**[delegated-admin gate](/docs/permissions/delegated-administration)**: 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
Expand Down Expand Up @@ -182,6 +182,9 @@ an administrator who can rewire a user's grants may read why they resolve as
they do. Studio's Access pillar ships a "why can this user access?" panel on
top of this endpoint.

Full request/response walkthrough, layer vocabulary, and caller-authorization
details: **[Explain Engine](/docs/permissions/explain)**.

## Governance: how "declared = enforced" is kept true

Four CI-time mechanisms make the security posture a **checked artifact**
Expand All @@ -198,6 +201,7 @@ rather than a belief:
`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.
Opt-in, format, and workflow: **[Access-Matrix Snapshot Gate](/docs/permissions/access-matrix)**.

- **Conformance matrix** (`packages/dogfood/test/authz-conformance.matrix.ts`,
ADR-0056 D10): every authorization primitive sits in exactly one honest
Expand Down
100 changes: 100 additions & 0 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
title: Delegated Administration
description: Administration itself as a scoped grant — a business-unit subtree, an action set, and an assignable-set allowlist, with self-escalation structurally impossible (ADR-0090 D12).
---

# Delegated Administration

A group with fifty subsidiaries cannot manage every grant from headquarters —
but handing each subsidiary a full admin is worse. ADR-0090 D12's answer:
**administration itself becomes a scoped capability**. A delegate can onboard
their own staff and reshuffle their own positions, but can never grant
anything outside an explicit allowlist — *including to themselves* — and can
never touch tenant-level assets.

Because the scope lives on an ordinary permission set, it is distributed via
positions, audited in the same tables, and explained by the same engine as
every other grant. No parallel admin subsystem.

## Authoring an admin scope

```typescript
export const FieldOpsDelegate = definePermissionSet({
name: 'field_ops_delegate',
label: 'Field Ops Delegate Admin',
// The scope authorizes WHAT may be administered…
adminScope: {
businessUnit: 'Field Operations', // WHERE: this sys_business_unit subtree
includeSubtree: true, // default true
manageAssignments: true, // user ↔ position rows (sys_user_position)
manageBindings: false, // position ↔ set rows (sys_position_permission_set)
authorEnvironmentSets: false, // may they author environment-owned sets?
assignablePermissionSets: ['showcase_contributor', 'showcase_manager'],
},
// …and plain CRUD on the RBAC link tables lets the requests through at all.
// Both are required: table CRUD with NO scope is refused outright.
objects: {
sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
sys_position: { allowRead: true },
sys_permission_set: { allowRead: true },
sys_business_unit: { allowRead: true },
sys_business_unit_member: { allowRead: true },
sys_user: { allowRead: true },
},
});
```

`businessUnit` names the subtree root by `sys_business_unit.name`; a
misconfigured name resolves to an empty subtree and **approves nothing**
(fail-closed).

## What the runtime gate enforces

Writes to the governed tables (`sys_user_position`,
`sys_position_permission_set`, `sys_user_permission_set`,
`sys_permission_set`) pass through the `DelegatedAdminGate`:

| Rule | Effect |
|---|---|
| Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ |
| Subtree anchoring | Assignments a delegate creates must be anchored (`sys_user_position.business_unit_id`) inside their subtree, and the target user must sit inside it |
| Allowlist, no self-escalation | Every set reached by the write — bound to the assigned position, or granted directly — must be on `assignablePermissionSets`. Granting an un-allowlisted set is refused **for anyone, including the delegate themselves** |
| Action flags | `manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree |
| Strict containment | Granting or authoring a set that itself carries an `adminScope` requires a held scope that **strictly contains** it — handing your own exact scope to a peer is refused (no lateral propagation) |
| Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked |
| Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically |
| Tenant-level assets stay tenant-level | The `everyone`/`guest` audience anchors and security-domain publishes are untouchable from any delegated scope |
| No scope, no admin | Holders of plain CRUD on the RBAC tables with **no** scope are refused: administration is a scoped capability now, not a side effect of table access |

Every rule above is exercised end-to-end by the showcase permission zoo
(`packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts`): the
in-subtree allowlisted assignment passes with a `granted_by` stamp; the
out-of-subtree anchor, the off-allowlist grant (to self), and the
`manageBindings: false` binding write are each refused.

## The assignment anchor

Positions never bind to a business unit at the *definition* level — that
would recreate the position-per-department explosion. The **assignment row**
may: `sys_user_position.business_unit_id` names the unit the person holds the
position *in* ("张三 is sales_manager **of 华东**"). It does exactly three
things: anchors that assignment's depth grants to the subtree, provides the
delegation boundary check above, and makes "manager of what" an auditable
fact. Capability bits are never BU-scoped.

## Explaining delegated decisions

The [explain engine](/docs/permissions/explain) closes the loop twice:

- explaining **another user** is authorized by `manage_users` *or* a delegated
`adminScope` whose subtree covers that user — a delegate can diagnose their
own people, and only their own people;
- contributor attribution plus the `granted_by` stamp answer both *who
granted this* and *who could have*.

## See also

- [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on
- [Positions](/docs/permissions/positions) — how delegates receive the scope
- [Explain Engine](/docs/permissions/explain)
- [Authorization Architecture](/docs/permissions/authorization)
107 changes: 107 additions & 0 deletions content/docs/permissions/explain.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
title: Explain Engine
description: Ask the runtime why a decision came out the way it did — every pipeline layer, with contributor attribution, over the kernel service or REST (ADR-0090 D6).
---

# Explain Engine

Authorization here is a nine-layer pipeline, and a nine-layer pipeline nobody
can interrogate is a support ticket generator. The explain engine
(ADR-0090 D6) is the first-class answer to *"why can 张三 PATCH 李四's
leave_request?"* — it walks the **same code paths the enforcement middleware
runs** (shared permission-set resolution, evaluator, FLS mask, RLS
composition), so the report is *explained by construction*, never a parallel
re-implementation that can drift.

## What a decision looks like

Each report carries the final verdict, the resolved principal, and one entry
per pipeline layer, in enforcement order:

```
principal → required_permissions → object_crud → fls → owd_baseline
→ depth → sharing → vama_bypass → rls
```

| Field | Meaning |
|---|---|
| `allowed` | The overall decision for `(object, operation, principal)` |
| `principal` | Resolved identity: `userId`, `positions[]`, `permissionSets[]`, optional `principalKind` / `onBehalfOf` |
| `layers[]` | One entry per layer: `layer`, `verdict`, human-readable `detail`, `contributors[]` |
| `readFilter` | For reads: the composed row filter actually applied — the machine artifact |

Layer verdicts are `grants` / `denies` / `narrows` / `widens` / `neutral` /
`not_applicable`. **Contributor attribution** names the permission set that
produced the verdict and how the caller holds it (position binding, additive
`everyone` baseline, direct grant) — e.g. the showcase auditor's read of
another user's private note reports:

```json
{
"layer": "vama_bypass",
"verdict": "widens",
"detail": "View/Modify All Data bypass held via [showcase_auditor] — ownership and sharing checks are skipped.",
"contributors": [{ "kind": "permission_set", "name": "showcase_auditor", "via": "position:auditor" }]
}
```

## Calling it

**Kernel service** (in-process, plugins/tests):

```typescript
const security = kernel.getService('security');
const decision = await security.explain(
{ object: 'showcase_private_note', operation: 'read', userId: targetUserId },
callerContext,
);
```

**REST** — same contract, two transports (`ExplainRequestSchema`):

```bash
# GET, query-string form
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/v1/security/explain?object=showcase_private_note&operation=read&userId=usr_123"

# POST, body form
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"object":"showcase_private_note","operation":"read","userId":"usr_123"}' \
"$BASE/api/v1/security/explain"
```

`operation` is one of `read | create | update | delete | transfer | restore |
purge` (defaults to `read`); omit `userId` to explain **yourself**.

## Who may ask

The endpoint is **authenticated-only** — even on `requireAuth: false`
deployments (an access report is sensitive even about oneself). Beyond that,
authorization lives in the *service*, so REST and in-process callers share one
rule:

- **Yourself** — always allowed.
- **Another user** — requires the `manage_users` capability **or** a delegated
`adminScope` whose business-unit subtree covers that user (ADR-0090 D12):
an administrator who can rewire a user's grants may read why they resolve
the way they do — and *only* a covering administrator. Anything else → 403.

A deployment without `@objectstack/plugin-security` answers 501.

## What it powers

- **The admin simulator** — Studio's Access pillar builds its "why can this
user access?" panel on this endpoint.
- **The [access-matrix snapshot gate](/docs/permissions/access-matrix)** —
the publish-time matrix is the same evaluation run over representative
(permission set × object) pairs; explain is the per-decision zoom lens.
- **Delegated-administration audits** — explain reports both *who granted*
(the `granted_by` stamp) and, via contributor attribution, *who could
have* ([delegated administration](/docs/permissions/delegated-administration)).

## See also

- [Authorization Architecture](/docs/permissions/authorization) — the pipeline the layers mirror
- [Access-Matrix Snapshot Gate](/docs/permissions/access-matrix)
- [Delegated Administration](/docs/permissions/delegated-administration)
- Reference: [Explain schema](/docs/references/security/explain)
3 changes: 3 additions & 0 deletions content/docs/permissions/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
"profiles",
"permission-sets",
"positions",
"delegated-administration",
"sharing-rules",
"field-level-security",
"permission-metadata",
"permissions-matrix",
"access-matrix",
"explain",
"access-recipes"
]
}
3 changes: 2 additions & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ suggestions after marketplace installs and in the Access pillar.
## Delegated administration — `adminScope` (ADR-0090 D12)

A permission set may carry an `adminScope`, making its holders **scoped
administrators**:
administrators** (full gate rules and runbook:
[Delegated Administration](/docs/permissions/delegated-administration)):

```typescript
export const EastSubsidiaryAdmin = definePermissionSet({
Expand Down
6 changes: 6 additions & 0 deletions content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,9 @@ flowchart TD
<Callout type="tip">
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation. This keeps security checks efficient even on tables with millions of rows.
</Callout>

## See also

- [Access-Matrix Snapshot Gate](/docs/permissions/access-matrix) — the CI gate that fails the build when this matrix drifts
- [Explain Engine](/docs/permissions/explain) — the per-decision runtime zoom lens
- [Delegated Administration](/docs/permissions/delegated-administration)
2 changes: 1 addition & 1 deletion packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const en: TranslationData = {
nav_invitations: { label: 'Invitations' },

// Access Control
nav_roles: { label: 'Roles' },
nav_positions: { label: 'Positions' },
nav_permission_sets: { label: 'Permission Sets' },
nav_sharing_rules: { label: 'Sharing Rules' },
nav_record_shares: { label: 'Record Shares' },
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const esES: TranslationData = {
nav_organizations: { label: 'Organizaciones' },
nav_invitations: { label: 'Invitaciones' },

nav_roles: { label: 'Roles' },
nav_positions: { label: 'Posiciones' },
nav_permission_sets: { label: 'Conjuntos de Permisos' },
nav_sharing_rules: { label: 'Reglas de Compartición' },
nav_record_shares: { label: 'Registros Compartidos' },
Expand Down
Loading