Skip to content

Commit d0531c4

Browse files
os-zhuangclaude
andauthored
docs(permissions),feat(setup-nav): standalone explain/matrix/delegated-admin pages + the last "Roles" label + objectui pin bump (#2759)
Docs: the three ADR-0090 P3/P4 capabilities folded into authorization.mdx get standalone, runbook-grade pages — explain.mdx (service + REST contract, layer vocabulary, D12 caller authorization), access-matrix.mdx (opt-in, real drift-failure output, snapshot format, review workflow), and delegated-administration.mdx (adminScope reference, the nine runtime gate rules — each proven by the showcase permission-zoo dogfood — and the assignment BU anchor). meta.json ordering + cross-links from the summaries that remain in place. Setup nav: the sys_position entry sheds the last "role" in platform UI copy (ADR-0090 D3) — nav_roles/"Roles" → nav_positions/"Positions" (岗位/ポジション/Posiciones across the four translation bundles). .objectui-sha → 397dcad73eaf: picks up the Studio Access-pillar explain panel ("why can this user access?", ADR-0090 D6, objectui#2375) and the suggested audience-binding install prompt (D5/D9, objectui#2376). Browser-verified against the rebuilt vendored console on a fresh boot: Setup sidebar shows 岗位; the Access pillar's 权限矩阵 renders all 8 zoo sets with per-object OWD + Ext dials; the 解释访问权限 panel returns the full layered decision (allowed banner, principal card, per-layer verdicts with contributor attribution) through the new REST endpoint. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1056c5f commit d0531c4

9 files changed

Lines changed: 328 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
Setup → Access Control nav: the `sys_position` entry is renamed
7+
`nav_roles`/"Roles" → `nav_positions`/"Positions" (岗位 / ポジション /
8+
Posiciones) — the last "role" leftover in platform UI copy (ADR-0090 D3;
9+
the Studio-side relabel already landed in objectui). The framework's
10+
`.objectui-sha` pin is bumped to pick up the Studio Access-pillar explain
11+
panel ("why can this user access?", ADR-0090 D6) and the suggested
12+
audience-binding install prompt (D5/D9).

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
16e2615f0bbe5d461d9381f1ec31bd2e85089c67
1+
397dcad73eaf6fdaa1740bff15f9d8faf45c4232
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
title: Access-Matrix Snapshot Gate
3+
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).
4+
---
5+
6+
# Access-Matrix Snapshot Gate
7+
8+
AI drafts permission metadata on this platform; the one thing it must never do
9+
is *silently* change who can do what. The access-matrix gate makes every
10+
capability change a **visible, reviewable build event**: the
11+
(permission set × object) matrix is derived purely from metadata on every
12+
`os compile`, diffed against a committed snapshot, and any drift **fails the
13+
build** with a semantic explanation until a human regenerates the snapshot.
14+
15+
## Opting in
16+
17+
The gate is per-app: commit an `access-matrix.json` next to
18+
`objectstack.config.ts`. Generate the first snapshot with:
19+
20+
```bash
21+
os compile --update-access-matrix
22+
```
23+
24+
No `access-matrix.json` → the gate is skipped (not opted in). Both
25+
`examples/app-crm` and `examples/app-showcase` are opted in.
26+
27+
## What a drift failure looks like
28+
29+
Change a grant — say, giving the showcase baseline set access to the private
30+
notes object — and the next `os compile` fails:
31+
32+
```
33+
→ Checking access-matrix snapshot (ADR-0090 D6)...
34+
35+
✗ Access matrix drift (1 change) — capability changes must be reviewed
36+
• 'showcase_member_default' gains access to 'showcase_private_note' (create, read, edit)
37+
If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.
38+
```
39+
40+
The diff lines are **semantic**, not JSON: gained/lost operations, View/Modify
41+
All changes, depth changes, OWD swings. If the change is intended, regenerate
42+
and commit — the snapshot's git diff then rides the pull request, where a
43+
reviewer sees the capability delta in plain language instead of spelunking
44+
through metadata files.
45+
46+
## The snapshot format
47+
48+
One entry per (permission set × object) grant, sorted for stable diffs
49+
(`AccessMatrixSchema`, version 1):
50+
51+
```json
52+
{
53+
"version": 1,
54+
"entries": [
55+
{
56+
"permissionSet": "showcase_auditor",
57+
"object": "showcase_private_note",
58+
"create": false,
59+
"read": true,
60+
"edit": false,
61+
"delete": false,
62+
"viewAllRecords": true,
63+
"modifyAllRecords": false,
64+
"sharingModel": "private"
65+
}
66+
]
67+
}
68+
```
69+
70+
`read` is also `true` when View/Modify All grants it; `edit`/`delete` are also
71+
`true` under Modify All — the matrix reports **effective** capability, not the
72+
raw flags. Each row carries the object's OWD so a baseline swing
73+
(`private``public_read_write`) is a visible diff too.
74+
75+
## How it relates to the rest of the defense
76+
77+
- The **[security posture linter](/docs/permissions/authorization#governance-how-declared--enforced-is-kept-true)**
78+
(ADR-0090 D7) rejects *individually dangerous* declarations (unset OWD,
79+
high-privilege anchor suggestions, retired aliases). The matrix gate catches
80+
what the linter can't: a *legitimate-looking* change whose blast radius a
81+
human should still sign off.
82+
- The **[explain engine](/docs/permissions/explain)** is the same evaluation
83+
logic pointed at one decision at runtime; the matrix is the build-time sweep
84+
across all of them.
85+
- Both are only possible because grants are **structured data** — the standing
86+
argument for the closed authoring vocabulary over freeform predicates.
87+
88+
## See also
89+
90+
- [Authorization Architecture](/docs/permissions/authorization)
91+
- [Explain Engine](/docs/permissions/explain)
92+
- [Security Permissions Matrix](/docs/permissions/permissions-matrix) — the human-readable capability tables

content/docs/permissions/authorization.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Two orthogonal identity-layer gates run before all of this: the ADR-0069
5757
session from protected resources while keeping remediation reachable), and
5858
**anti-escalation** — RBAC tables are read-only for `organization_admin`, and
5959
since ADR-0090 D12 every RBAC-table write is additionally checked by the
60-
**delegated-admin gate**: tenant admins pass, delegates are confined to their
60+
**[delegated-admin gate](/docs/permissions/delegated-administration)**: tenant admins pass, delegates are confined to their
6161
`adminScope` (BU subtree + assignable-set allowlist, no self-escalation,
6262
strict containment for scope grants), and everyone else is denied. The
6363
`everyone`/`guest` audience-anchor bindings reject high-privilege sets at the
@@ -182,6 +182,9 @@ an administrator who can rewire a user's grants may read why they resolve as
182182
they do. Studio's Access pillar ships a "why can this user access?" panel on
183183
top of this endpoint.
184184

185+
Full request/response walkthrough, layer vocabulary, and caller-authorization
186+
details: **[Explain Engine](/docs/permissions/explain)**.
187+
185188
## Governance: how "declared = enforced" is kept true
186189

187190
Four CI-time mechanisms make the security posture a **checked artifact**
@@ -198,6 +201,7 @@ rather than a belief:
198201
`os compile` fails on any capability drift with semantic lines
199202
(`'crm_admin' gains delete on 'crm_lead'`) until the snapshot is updated via
200203
`--update-access-matrix` — the snapshot's git diff is the review artifact.
204+
Opt-in, format, and workflow: **[Access-Matrix Snapshot Gate](/docs/permissions/access-matrix)**.
201205

202206
- **Conformance matrix** (`packages/dogfood/test/authz-conformance.matrix.ts`,
203207
ADR-0056 D10): every authorization primitive sits in exactly one honest
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
title: Delegated Administration
3+
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).
4+
---
5+
6+
# Delegated Administration
7+
8+
A group with fifty subsidiaries cannot manage every grant from headquarters —
9+
but handing each subsidiary a full admin is worse. ADR-0090 D12's answer:
10+
**administration itself becomes a scoped capability**. A delegate can onboard
11+
their own staff and reshuffle their own positions, but can never grant
12+
anything outside an explicit allowlist — *including to themselves* — and can
13+
never touch tenant-level assets.
14+
15+
Because the scope lives on an ordinary permission set, it is distributed via
16+
positions, audited in the same tables, and explained by the same engine as
17+
every other grant. No parallel admin subsystem.
18+
19+
## Authoring an admin scope
20+
21+
```typescript
22+
export const FieldOpsDelegate = definePermissionSet({
23+
name: 'field_ops_delegate',
24+
label: 'Field Ops Delegate Admin',
25+
// The scope authorizes WHAT may be administered…
26+
adminScope: {
27+
businessUnit: 'Field Operations', // WHERE: this sys_business_unit subtree
28+
includeSubtree: true, // default true
29+
manageAssignments: true, // user ↔ position rows (sys_user_position)
30+
manageBindings: false, // position ↔ set rows (sys_position_permission_set)
31+
authorEnvironmentSets: false, // may they author environment-owned sets?
32+
assignablePermissionSets: ['showcase_contributor', 'showcase_manager'],
33+
},
34+
// …and plain CRUD on the RBAC link tables lets the requests through at all.
35+
// Both are required: table CRUD with NO scope is refused outright.
36+
objects: {
37+
sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
38+
sys_position: { allowRead: true },
39+
sys_permission_set: { allowRead: true },
40+
sys_business_unit: { allowRead: true },
41+
sys_business_unit_member: { allowRead: true },
42+
sys_user: { allowRead: true },
43+
},
44+
});
45+
```
46+
47+
`businessUnit` names the subtree root by `sys_business_unit.name`; a
48+
misconfigured name resolves to an empty subtree and **approves nothing**
49+
(fail-closed).
50+
51+
## What the runtime gate enforces
52+
53+
Writes to the governed tables (`sys_user_position`,
54+
`sys_position_permission_set`, `sys_user_permission_set`,
55+
`sys_permission_set`) pass through the `DelegatedAdminGate`:
56+
57+
| Rule | Effect |
58+
|---|---|
59+
| Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ |
60+
| 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 |
61+
| 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** |
62+
| 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 |
63+
| 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) |
64+
| Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked |
65+
| Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically |
66+
| Tenant-level assets stay tenant-level | The `everyone`/`guest` audience anchors and security-domain publishes are untouchable from any delegated scope |
67+
| 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 |
68+
69+
Every rule above is exercised end-to-end by the showcase permission zoo
70+
(`packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts`): the
71+
in-subtree allowlisted assignment passes with a `granted_by` stamp; the
72+
out-of-subtree anchor, the off-allowlist grant (to self), and the
73+
`manageBindings: false` binding write are each refused.
74+
75+
## The assignment anchor
76+
77+
Positions never bind to a business unit at the *definition* level — that
78+
would recreate the position-per-department explosion. The **assignment row**
79+
may: `sys_user_position.business_unit_id` names the unit the person holds the
80+
position *in* ("张三 is sales_manager **of 华东**"). It does exactly three
81+
things: anchors that assignment's depth grants to the subtree, provides the
82+
delegation boundary check above, and makes "manager of what" an auditable
83+
fact. Capability bits are never BU-scoped.
84+
85+
## Explaining delegated decisions
86+
87+
The [explain engine](/docs/permissions/explain) closes the loop twice:
88+
89+
- explaining **another user** is authorized by `manage_users` *or* a delegated
90+
`adminScope` whose subtree covers that user — a delegate can diagnose their
91+
own people, and only their own people;
92+
- contributor attribution plus the `granted_by` stamp answer both *who
93+
granted this* and *who could have*.
94+
95+
## See also
96+
97+
- [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on
98+
- [Positions](/docs/permissions/positions) — how delegates receive the scope
99+
- [Explain Engine](/docs/permissions/explain)
100+
- [Authorization Architecture](/docs/permissions/authorization)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
title: Explain Engine
3+
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).
4+
---
5+
6+
# Explain Engine
7+
8+
Authorization here is a nine-layer pipeline, and a nine-layer pipeline nobody
9+
can interrogate is a support ticket generator. The explain engine
10+
(ADR-0090 D6) is the first-class answer to *"why can 张三 PATCH 李四's
11+
leave_request?"* — it walks the **same code paths the enforcement middleware
12+
runs** (shared permission-set resolution, evaluator, FLS mask, RLS
13+
composition), so the report is *explained by construction*, never a parallel
14+
re-implementation that can drift.
15+
16+
## What a decision looks like
17+
18+
Each report carries the final verdict, the resolved principal, and one entry
19+
per pipeline layer, in enforcement order:
20+
21+
```
22+
principal → required_permissions → object_crud → fls → owd_baseline
23+
→ depth → sharing → vama_bypass → rls
24+
```
25+
26+
| Field | Meaning |
27+
|---|---|
28+
| `allowed` | The overall decision for `(object, operation, principal)` |
29+
| `principal` | Resolved identity: `userId`, `positions[]`, `permissionSets[]`, optional `principalKind` / `onBehalfOf` |
30+
| `layers[]` | One entry per layer: `layer`, `verdict`, human-readable `detail`, `contributors[]` |
31+
| `readFilter` | For reads: the composed row filter actually applied — the machine artifact |
32+
33+
Layer verdicts are `grants` / `denies` / `narrows` / `widens` / `neutral` /
34+
`not_applicable`. **Contributor attribution** names the permission set that
35+
produced the verdict and how the caller holds it (position binding, additive
36+
`everyone` baseline, direct grant) — e.g. the showcase auditor's read of
37+
another user's private note reports:
38+
39+
```json
40+
{
41+
"layer": "vama_bypass",
42+
"verdict": "widens",
43+
"detail": "View/Modify All Data bypass held via [showcase_auditor] — ownership and sharing checks are skipped.",
44+
"contributors": [{ "kind": "permission_set", "name": "showcase_auditor", "via": "position:auditor" }]
45+
}
46+
```
47+
48+
## Calling it
49+
50+
**Kernel service** (in-process, plugins/tests):
51+
52+
```typescript
53+
const security = kernel.getService('security');
54+
const decision = await security.explain(
55+
{ object: 'showcase_private_note', operation: 'read', userId: targetUserId },
56+
callerContext,
57+
);
58+
```
59+
60+
**REST** — same contract, two transports (`ExplainRequestSchema`):
61+
62+
```bash
63+
# GET, query-string form
64+
curl -H "Authorization: Bearer $TOKEN" \
65+
"$BASE/api/v1/security/explain?object=showcase_private_note&operation=read&userId=usr_123"
66+
67+
# POST, body form
68+
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
69+
-d '{"object":"showcase_private_note","operation":"read","userId":"usr_123"}' \
70+
"$BASE/api/v1/security/explain"
71+
```
72+
73+
`operation` is one of `read | create | update | delete | transfer | restore |
74+
purge` (defaults to `read`); omit `userId` to explain **yourself**.
75+
76+
## Who may ask
77+
78+
The endpoint is **authenticated-only** — even on `requireAuth: false`
79+
deployments (an access report is sensitive even about oneself). Beyond that,
80+
authorization lives in the *service*, so REST and in-process callers share one
81+
rule:
82+
83+
- **Yourself** — always allowed.
84+
- **Another user** — requires the `manage_users` capability **or** a delegated
85+
`adminScope` whose business-unit subtree covers that user (ADR-0090 D12):
86+
an administrator who can rewire a user's grants may read why they resolve
87+
the way they do — and *only* a covering administrator. Anything else → 403.
88+
89+
A deployment without `@objectstack/plugin-security` answers 501.
90+
91+
## What it powers
92+
93+
- **The admin simulator** — Studio's Access pillar builds its "why can this
94+
user access?" panel on this endpoint.
95+
- **The [access-matrix snapshot gate](/docs/permissions/access-matrix)**
96+
the publish-time matrix is the same evaluation run over representative
97+
(permission set × object) pairs; explain is the per-decision zoom lens.
98+
- **Delegated-administration audits** — explain reports both *who granted*
99+
(the `granted_by` stamp) and, via contributor attribution, *who could
100+
have* ([delegated administration](/docs/permissions/delegated-administration)).
101+
102+
## See also
103+
104+
- [Authorization Architecture](/docs/permissions/authorization) — the pipeline the layers mirror
105+
- [Access-Matrix Snapshot Gate](/docs/permissions/access-matrix)
106+
- [Delegated Administration](/docs/permissions/delegated-administration)
107+
- Reference: [Explain schema](/docs/references/security/explain)

content/docs/permissions/meta.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
"profiles",
1010
"permission-sets",
1111
"positions",
12+
"delegated-administration",
1213
"sharing-rules",
1314
"field-level-security",
1415
"permission-metadata",
1516
"permissions-matrix",
17+
"access-matrix",
18+
"explain",
1619
"access-recipes"
1720
]
1821
}

content/docs/permissions/permission-sets.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ suggestions after marketplace installs and in the Access pillar.
159159
## Delegated administration — `adminScope` (ADR-0090 D12)
160160

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

164165
```typescript
165166
export const EastSubsidiaryAdmin = definePermissionSet({

content/docs/permissions/permissions-matrix.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,9 @@ flowchart TD
307307
<Callout type="tip">
308308
**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.
309309
</Callout>
310+
311+
## See also
312+
313+
- [Access-Matrix Snapshot Gate](/docs/permissions/access-matrix) — the CI gate that fails the build when this matrix drifts
314+
- [Explain Engine](/docs/permissions/explain) — the per-decision runtime zoom lens
315+
- [Delegated Administration](/docs/permissions/delegated-administration)

0 commit comments

Comments
 (0)