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
51 changes: 51 additions & 0 deletions .changeset/23488-admin-scope-binding-authoring-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-security': minor
'@objectstack/core': patch
'@objectstack/platform-objects': patch
---

feat(spec)!: retire the delegated-admin scope's `manageBindings` and `authorEnvironmentSets` — rights no door honoured (#23488)

**BREAKING** — `manageBindings` and `authorEnvironmentSets` on a permission set's `adminScope` (`AdminScopeSchema`, ADR-0090 D12) are now refused at parse. They named the right to bind sets to positions and the right to author environment permission sets, and they gated the `sys_position_permission_set` and `sys_permission_set` rows, which ADR-0131 D3 retired. A position's sets are part of its definition and a permission set is a definition; both are written through the metadata door, which admits a `manage_metadata` holder and reads no `adminScope`. So a delegate who "may bind" or "may author" was granted nothing. Delete both keys: a delegate's scope is placement, assignment and the allowlist.

Clause-②: yes (narrowing)

Measured before removal, at `cdc42eb49`: no production author set either key to `true` (the showcase wrote the default `false`; the control key `manageAssignments: true` has four production authors), and no door that writes a position or a permission set consulted either key. Their one reader was the delegated-admin gate's strict-containment comparison, which changes below.

## FROM → TO

| you wrote (17.7 and earlier) | write instead |
| --- | --- |
| `adminScope: { …, manageBindings: true \| false }` | nothing — delete the key |
| `adminScope: { …, authorEnvironmentSets: true \| false }` | nothing — delete the key |
| a delegate who re-composes what a position distributes, or authors a set's body | a `manage_metadata` holder writes the position or set definition through the metadata door (ADR-0131 D3); the delegate assigns people to positions and grants allowlisted sets inside its subtree |
| reading `scopes[].manageBindings` / `scopes[].authorEnvironmentSets` from `GET /api/v1/security/my-delegable-scope` (`DelegableAdminScope`) | nothing — the report carries `manageAssignments` as the scope's one action right |

**The one-line fix:** delete `manageBindings` and `authorEnvironmentSets` from every `adminScope`.

**What an author who still writes either sees.** `tsc` fails at the authoring site (`AdminScope` types both keys as retired), and the parse — `definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name` — refuses it at its own path with the prescription:

> `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.

## The containment change (`@objectstack/plugin-security`)

The delegated-admin gate judges a delegate who hands out a set that itself carries an `adminScope` — a `sys_user_position` assignment of a position that distributes it, or a direct `sys_user_permission_set` grant — by **strict containment**. That comparison read both retired flags. It now reads `includeSubtree`, `manageAssignments` and the allowlist only:

- a delegate whose own scope set either flag `true` can **no longer** hand out a scope otherwise EQUAL to its own — the inert flag used to count as the strictly-larger axis and let that lateral copy through the equal-scope refusal. It is refused `403 PERMISSION_DENIED` (`… would hand out an adminScope not strictly contained by yours`);
- a granted scope that set either flag `true` no longer counts as out-reaching a delegate that did not.

`DelegatedAdminGate.describeDelegableScope()` (and so `GET /api/v1/security/my-delegable-scope`) no longer reports either flag.

## The retirement kit

- **Two `retiredKey()` tombstones** on `AdminScopeSchema`, a `strictObject` — so each refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/AdminScope:manageBindings`, `security/AdminScope:authorEnvironmentSets`. `DelegableAdminScope` (`@objectstack/spec/contracts`) drops both fields.
- **The D2 conversion `permission-admin-scope-binding-authoring-removed`** (protocol 18, retired from the load path) deletes both keys from every `permissions[].adminScope`, one notice per key, whatever they hold. Both keys carried the default `false`, so a 17.x parse materialized them in every declared scope; the rehydration seams replay this conversion over stored `permission` rows and built artifacts, and `os migrate meta --stored` lists the rows that carry them before the upgrade.
- **The D3 entry `permission-admin-scope-binding-authoring-retired`** records the containment change above: the judgement the strip cannot make for the upgrader.
- **`@objectstack/core`:** `permissionSetDefinitionFromRow` — the mapping `os migrate`'s legacy-row ceremony saves a `sys_permission_set` row through — drops both keys from the row's `admin_scope`. That save goes through `saveMetaItem`, which replays no conversion: measured, a 17.x row's body answered `422 INVALID_METADATA` at `adminScope.manageBindings`, and the same body without the keys saved.
- **`@objectstack/platform-objects`:** the permission-set form's `adminScope` help text (all four locales) names the scope's live keys only.
- **No deprecation window**, per the project's startup-stage posture.

⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's two switches for these keys retire in an objectui follow-up.

<!-- adr-0087: registered permission-admin-scope-binding-authoring-removed, permission-admin-scope-binding-authoring-retired -->
4 changes: 2 additions & 2 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ set, and the gate resolves the name in the security catalog. The gate's rules:
| 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` gates the assignment rows — `sys_user_position`, and direct `sys_user_permission_set` grants. `manageBindings` and `authorEnvironmentSets` gated writes to the binding rows and set rows of the retired `sys_position_permission_set` and `sys_permission_set` objects; neither object is registered any more, so neither flag admits a write. A position's sets are part of its definition and a permission set is a definition: both are written through the metadata door, which admits a `manage_metadata` holder (the platform operator under `group` or `isolated`) and reads no `adminScope` (ADR-0131 D3). The two flags still count in the strict-containment comparison below |
| 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) |
| Action flag | `manageAssignments` gates the assignment rows — `sys_user_position`, and direct `sys_user_permission_set` grants — and is the scope's only action right. A position's sets are part of its definition and a permission set is a definition: both are written through the metadata door, which admits a `manage_metadata` holder (the platform operator under `group` or `isolated`) and reads no `adminScope` (ADR-0131 D3). The scope's former `manageBindings` and `authorEnvironmentSets` named the retired `sys_position_permission_set` and `sys_permission_set` rows, admitted no write once those objects were gone, and are retired: the schema refuses both with the fix |
| Strict containment | Granting a set that itself carries an `adminScope` — assigning a position that distributes it, or granting it directly — requires a held scope that **strictly contains** it: the same root, no wider subtree, `manageAssignments` only if yours has it, an allowlist inside yours, and smaller on at least one of those. 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 |
Expand Down
8 changes: 5 additions & 3 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,11 @@ export const EastSubsidiaryAdmin = definePermissionSet({
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.
when granting a set that itself carries an `adminScope` (handing out your own
exact scope is refused). A delegate's scope is placement, assignment and the
allowlist: writing a set's body or a position's sets is a metadata write, judged
by `manage_metadata`, never by an `adminScope`. Anchors and security publishes
stay tenant-level.

## Provenance — package vs environment sets (ADR-0086)

Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ const result = AdminScopeSchema.parse(data);
| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root. Required and non-blank: an empty or whitespace-only value names no business unit and is refused. |
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) |
| **manageBindings** | `never` | optional | [REMOVED] `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **authorEnvironmentSets** | `never` | optional | [REMOVED] `permission.adminScope.authorEnvironmentSets` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once permission sets stopped being rows: a permission set is an environment metadata definition (ADR-0131 D3), written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out |


Expand Down Expand Up @@ -125,7 +125,7 @@ const result = AdminScopeSchema.parse(data);
| **systemPermissions** | `string[]` | optional | System level capabilities |
| **tabPermissions** | `Record<string, Enum<'visible' \| 'hidden' \| 'default_on' \| 'default_off'>>` | optional | App/tab visibility: visible, hidden, default_on (shown by default), default_off (available but hidden initially) |
| **rowLevelSecurity** | `{ name: string; label?: string; description?: string; object: string; … }[]` | optional | Row-level security policies (see rls.zod.ts for full spec) |
| **adminScope** | `{ businessUnit: string; includeSubtree?: boolean; manageAssignments?: boolean; manageBindings?: boolean; … }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |
| **adminScope** | `{ businessUnit: string; includeSubtree?: boolean; manageAssignments?: boolean; assignablePermissionSets?: string[] }` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this permission set. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand Down Expand Up @@ -182,8 +182,8 @@ const result = AdminScopeSchema.parse(data);
| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root. Required and non-blank: an empty or whitespace-only value names no business unit and is refused. |
| **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) |
| **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree |
| **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree |
| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) |
| **manageBindings** | `never` | optional | [REMOVED] `permission.adminScope.manageBindings` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once the position-to-permission-set binding rows it gated were retired: a position's sets are part of its definition (ADR-0131 D3), and that definition is written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **authorEnvironmentSets** | `never` | optional | [REMOVED] `permission.adminScope.authorEnvironmentSets` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0090 D12) — nothing honoured it once permission sets stopped being rows: a permission set is an environment metadata definition (ADR-0131 D3), written through the metadata door, which admits a `manage_metadata` holder and reads no adminScope. Delete the key; a delegate's scope is placement, assignment and the allowlist — `businessUnit` with `includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out |

### Nested Shape: `PermissionSet.protection`
Expand Down
20 changes: 11 additions & 9 deletions docs/design/permission-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ itself is a scoped grant*. Example: the 华东 subsidiary's IT admin receives an
that says:

- *where*: the 华东 business-unit subtree only;
- *what*: assign users to positions, bind sets to positions — inside that subtree;
- *what*: assign users to positions, and grant allowlisted sets directly — inside that subtree;
- *which*: an **allowlist** of sets they may hand out (say, the CRM and HR self-service sets).

They can onboard their own staff and reshuffle their own positions, but they can never grant
Expand All @@ -303,19 +303,21 @@ things (the `everyone`/`guest` anchors, security publishes). Headquarters keeps
the same explain engine answers "who *could have* granted this", not just "who did".

**How it is authored (landed in P3).** An admin scope is a field on an ordinary permission set
(`adminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings,
authorEnvironmentSets, assignablePermissionSets[] }`), so it is distributed via positions and
audited like every other grant. The same set should also carry plain CRUD on the RBAC link
tables (`sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`) — the
scope authorizes *what* may be administered, the CRUD bits let the requests through at all.
(`adminScope: { businessUnit, includeSubtree, manageAssignments, assignablePermissionSets[] }`),
so it is distributed via positions and audited like every other grant. The same set should also
carry plain CRUD on the assignment tables (`sys_user_position`, `sys_user_permission_set`) — the
scope authorizes *what* may be administered, the CRUD bits let the requests through at all. A
position's sets and a set's body are environment definitions written through the metadata door by
a `manage_metadata` holder (ADR-0131 D3), so binding and set authoring are not delegable: the
scope's former `manageBindings` and `authorEnvironmentSets` retired with the binding and set rows
they gated.
Runtime rules enforced by the `DelegatedAdminGate` (plugin-security):

- assignments a delegate creates must be **anchored** (`sys_user_position.business_unit_id`)
inside their subtree, and are `granted_by`-stamped automatically;
- every set reached by the write — bound to the assigned position, or granted directly — must be
in the allowlist; re-composing a position (bindings) requires every current holder to sit
inside the subtree;
- granting or authoring a set that itself carries an `adminScope` requires a held scope that
in the allowlist;
- granting 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);
- delegates write **single rows by id** only (a broad filter-write cannot be boundary-checked);
Expand Down
Loading
Loading