diff --git a/.changeset/setup-nav-positions-label.md b/.changeset/setup-nav-positions-label.md new file mode 100644 index 0000000000..888c870dae --- /dev/null +++ b/.changeset/setup-nav-positions-label.md @@ -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). diff --git a/.objectui-sha b/.objectui-sha index bc42d3fd10..105544e0ee 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -16e2615f0bbe5d461d9381f1ec31bd2e85089c67 +397dcad73eaf6fdaa1740bff15f9d8faf45c4232 diff --git a/content/docs/permissions/access-matrix.mdx b/content/docs/permissions/access-matrix.mdx new file mode 100644 index 0000000000..c57482c8ae --- /dev/null +++ b/content/docs/permissions/access-matrix.mdx @@ -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 diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 958fe8a035..038d51130d 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -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 @@ -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** @@ -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 diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx new file mode 100644 index 0000000000..bfe20c6e4e --- /dev/null +++ b/content/docs/permissions/delegated-administration.mdx @@ -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) diff --git a/content/docs/permissions/explain.mdx b/content/docs/permissions/explain.mdx new file mode 100644 index 0000000000..57c8151852 --- /dev/null +++ b/content/docs/permissions/explain.mdx @@ -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) diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json index 1813d6ffad..c4cdd63793 100644 --- a/content/docs/permissions/meta.json +++ b/content/docs/permissions/meta.json @@ -9,10 +9,13 @@ "profiles", "permission-sets", "positions", + "delegated-administration", "sharing-rules", "field-level-security", "permission-metadata", "permissions-matrix", + "access-matrix", + "explain", "access-recipes" ] } diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 4eb0527336..21fb4b9c59 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -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({ diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index 5d8923905d..cbfb31273c 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -307,3 +307,9 @@ flowchart TD **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. + +## 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) diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 2168e256a0..292390c18a 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -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' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index fdb4ede768..ca3c06708d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -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' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index b831242b21..aa808a4515 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -51,7 +51,7 @@ export const jaJP: TranslationData = { nav_organizations: { label: '組織' }, nav_invitations: { label: '招待' }, - nav_roles: { label: 'ロール' }, + nav_positions: { label: 'ポジション' }, nav_permission_sets: { label: '権限セット' }, nav_sharing_rules: { label: '共有ルール' }, nav_record_shares: { label: 'レコード共有' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 4c04e80758..a0df06a66b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -52,7 +52,7 @@ export const zhCN: TranslationData = { nav_organizations: { label: '组织' }, nav_invitations: { label: '邀请' }, - nav_roles: { label: '角色' }, + nav_positions: { label: '岗位' }, nav_capabilities: { label: '能力' }, nav_permission_sets: { label: '权限集' }, nav_sharing_rules: { label: '共享规则' }, diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 3234f28c68..f3f39f3743 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -252,7 +252,7 @@ export class SecurityPlugin implements Plugin { group: 'group_access_control', priority: 100, items: [ - { id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_position', icon: 'shield-check' }, + { id: 'nav_positions', type: 'object', label: 'Positions', objectName: 'sys_position', icon: 'shield-check' }, { id: 'nav_capabilities', type: 'object', label: 'Capabilities', objectName: 'sys_capability', icon: 'badge-check' }, { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' }, ],