Skip to content

Commit b27f8de

Browse files
feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary (#24732)
Fixes #24136 Clause-②: no ## What this does ADR-0090 D12, as amended on 2026-10-17 (ruling of record 6228728551 on #23960, letter B), names three kinds of administrator: tenant-level, organization-level and delegated. Until now `plugin-security` enforced only the first and the last. This PR enforces the organization-level kind, exactly as the ADR note defines it. There is no fourth condition and no new denylist entry beyond the three agent ceilings the ADR names. **The boundary.** An organization administrator holds `organization_admin` or `organization_admin_no_bypass` for its active organization and is not a platform administrator. It may create and remove `sys_user_position` and `sys_user_permission_set` rows of its own organization, for that organization's members. Every permission set the row distributes must be inside the organization boundary. For a direct grant, that is the granted set. For a position, it is each set the position's definition lists, plus the set of the position's own name, which the resolver also grants. A set is inside when: - (a) its `objects['*']` entry carries neither `viewAllRecords` nor `modifyAllRecords`; - (b) it has no `adminScope`; - (c) every name in its `systemPermissions` is a capability the security catalog declares `scope: 'org'`. A name no declaration carries is not an organization capability. Outside by name: `admin_full_access`, `organization_admin`, `organization_admin_no_bypass`, and the three D10 agent ceilings (`mcp_agent_data_read`, `mcp_agent_data_write`, `mcp_agent_restricted`, the ADR's denylist). A set the catalog does not hold, and a position the catalog does not hold, cannot be shown to be inside, so they are outside too. **The refusals.** Each answers `403 PERMISSION_DENIED`. The message names the set and the failed condition, or names the subject the refusal is about: - any out-of-boundary set, to anyone, the administrator itself included; - a position with one set outside: the whole position is outside; - a row naming another organization; - removal of a foreign-organization row, or of an organization-less row (that row applies in every organization); - a business unit of another organization as the assignment's anchor; - an edit in place: the right is create and remove only; - a filter delete; - a caller with no active organization; - organization membership (`sys_member`) rows: administrator status comes from the membership grade alone. A row inserted with no organization is stamped with the caller's organization once admitted, so this right never mints an organization-less grant. A target who is not a member of the organization is refused `400 VALIDATION_FAILED` by the existing grant-holder membership hooks. Those hooks judge every writer and are not duplicated here. The ADR-0068 D2 identity positions (`platform_admin`, `org_*`) are likewise refused `400` by the existing object rule, for every writer. ## Changes (all in `@objectstack/plugin-security`) 1. **Gate, third branch** (`delegated-admin-gate.ts`). "Tenant-level" now means a superuser wildcard from any set other than the two organization-administrator sets. The walled `organization_admin` was read as tenant-level before, and the ADR note says it is organization-level. A caller holding an organization-administrator set is judged by `organizationAdminFailure` after the self-delegation path. An organization administrator that also holds an `adminScope` falls back to its D12 delegated scopes when the organization boundary refuses. Platform administrators and D12 delegates take exactly the paths they took before, and everyone else is denied as before. The exported `isTenantAdmin` is unchanged. 2. **Overrides** (`objects/default-permission-sets.ts`). `organization_admin` gets `allowCreate: true` and `allowDelete: true` on both assignment tables, and `allowEdit` stays `false`. `deriveWallLessOrgAdmin` carries this to the no-bypass variant unchanged. The anti-escalation comment now states the boundary. 3. **Two doors.** - `describeDelegableScope`, and through it `GET /security/my-delegable-scope`, reports an organization administrator with `isTenantAdmin: false`. Every unit of its organization is placeable. Only positions inside the boundary are assignable, which excludes the reserved identity names the write path refuses. - `explainAccessForCaller` admits an organization administrator explaining a member of its own organization (`organizationAdminCoversUser`). - The REST route is unchanged, and so is the spec's `DelegableScope` shape. 4. **Arguments updated.** The headers of `invitation-placement.ts` and `auto-org-admin-grant.ts` now state the boundary as the safety premise instead of the read-only overrides. 5. A `minor` changeset for `@objectstack/plugin-security` (ruling item 3). There is no `packages/spec` change, no new endpoint and no membership-model change. ## Tests - `delegated-admin-gate.test.ts`: a new describe for the organization administrator, run for both variants. It covers in-boundary sets and positions; every condition and every by-name exclusion; a mixed bundle; the position's own-name set; self-assignment; a foreign-organization row and foreign or organization-less removals; a foreign business unit; edit in place; filter delete; no organization; `sys_member`; the platform administrator unchanged; a member denied; the organization administrator that is also a delegate; and both doors. Every refusal asserts `code` and `status`. - `objects/rbac-objects.test.ts`: the old read-only pin is now the boundary pin, run over the sets this package actually ships. Neither variant can assign `admin_full_access`, an `adminScope` set, a platform-capability set or either organization-administrator set, to anyone, itself included. `member_default`, `viewer_readonly` and an organization-capability set are the in-boundary controls. - `invitation-placement.test.ts`: an organization administrator's invitation, judged by the real gate. In-boundary positions are issuable. A position carrying `admin_full_access`, or a unit of another organization, is refused. - `org-admin-assignment-boundary.posture.test.ts` (new): the three-posture acceptance on a real stack. It runs a real `ObjectQL`, a real SQLite `SqlDriver` and the real `SecurityPlugin`: gate, overrides, CRUD and membership hooks in production order. - The matrix: `single`, `group` and `isolated`; platform administrator, `organization_admin`, `organization_admin_no_bypass` and member; insert and delete; both tables; an in-boundary and an out-of-boundary set. That is 3 × 4 × 2 × 2 = 48 cells, each asserting both insert and delete. - In every posture, both organization-administrator variants answer 200 for in-boundary inserts and deletes, and the stored row carries the administrator's organization. Out-of-boundary inserts and deletes answer `403 PERMISSION_DENIED` naming `admin_full_access` and the by-name condition. The platform administrator is admitted in every cell, and the member is refused in every cell. - Per posture, it also covers: a member of another organization (400), the identity positions (400), edit in place (403), the delegable-scope report and explain. - `SharingServicePlugin` is not composed: the assignment tables carry no sharing, and the stack the gate judges is complete without it. - `get-effective-object-permissions.test.ts`: the #20136 parity case followed the overrides. Edit stays refused, create and delete are now granted, and `can()` agrees with the evaluator. - `docs/qa/platform-checklist/areas/access-security.json`: one item, `access-security.org-admin-assignment-boundary`, recording this run. Its pins are the three files above. ### Ablations (each negative pin, forbidden behaviour put back, red, then restored byte-identical to HEAD) | # | Mutation in `delegated-admin-gate.ts` | Result over the four boundary files | |---|---|---| | 1 | `organizationBoundaryFailure` admits every set | 30 failed / 201, all four files red | | 2 | the row-organization check never fires | 2 failed / 201 | | 3 | the position's own-name set is not counted | 3 failed / 201 | | 4 | edit in place admitted by the gate | 2 failed / 201 (the gate unit pins; on the real stack `allowEdit: false` still refuses: defence in depth) | | 5 | explain covers any user | 4 failed / 201 | | 6 | the walled `organization_admin` read as tenant-level again (the old `isTenantAdmin` short-circuit) | 19 failed / 201, all four files red | Every leg ran through `scripts/ablation-replace.mjs`: anchor 1 → 0, the blob moved, and the restore was verified as `blob == HEAD`, `git diff HEAD` empty. The tests import `src` directly, so no `dist` was involved. ### Verification at `20eb5f1f` (the head this PR opens with) - Closure build `pnpm --filter '@objectstack/plugin-security^...' build`: `VERDICT command-exit 0`. - `@objectstack/plugin-security` full suite: 190 files passed, 3883 passed, 45 skipped. `typecheck`: exit 0. - `dispatch-gates --commands` (no paths) derived 71 families. Run with exit codes captured before any pipe: 70 exit 0, 1 exit 3. The exit-3 one is `check-plugin-teardown-shape.mjs --self-test`, whose positive control is pinned to a commit this shallow clone cannot reach. It is NOT MEASURED and left to CI. `--ran` reconciliation: 71 derived, 70 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. - `pnpm check:where-matcher` reddened the first gate run, because the new test double read `$`-combinators as field names. It now refuses them, and the gate passes. - `test:repo` runs in CI. ## Rulings carried - B on #23960 (6228728551), items 1–4: the three conditions, no spec key, `Clause-②: no`, `minor`, and the denylist fallback as the ADR names it (the three agent ceilings). - E′ on #24164 (6250748677), item 9: no fourth condition. - B on #24341 (6278784335): a delegated principal's own ceiling leg collects no object default. This PR does not touch that collection. ## Decisions made in this PR (each also in the dev report) 1. **Membership of the target** is judged by the existing grant-holder membership hooks (400, every writer), not again in the gate. One rule has one owner, and a second copy would answer a different code depending on order. 2. **An organization administrator that is also a delegate** is tried against the organization boundary first, then against its `adminScope`. If neither admits the write, the refusal is the delegated one. 3. **A row inserted with no organization** is stamped with the caller's organization by the gate once admitted. This is a belt: the organization-less row it prevents would apply in every organization. 4. **A custom set with a superuser wildcard** that is not one of the two organization-administrator sets stays tenant-level, as today. The ADR scopes the change to the two named sets. 5. **The organization administrator's picker** leaves out the ADR-0068 D2 identity names, which the boundary does not settle (they carry no sets) and which the write path refuses for every writer. ## Acceptance notes - **#23960's measurement re-run.** It was not re-run through the HTTP door: this container serves no app. The real-stack posture file stands in. The four writes that answered 403 (insert and delete on both tables, both variants) answer 200 for an in-boundary set in all three postures. - **Console check (Setup holders page Add / Remove, the invite dialog's placement section).** NOT MEASURED, because objectui is not in this session's repository scope. The server side the console reads changed as follows: the grant now opens create and delete, and `my-delegable-scope` reports placeable units and assignable positions. Expect no console code; a measured gap there is a `domain:ui` card. - **Stale comment outside this card's file surface.** `packages/plugins/plugin-auth/src/auth-manager.ts` (the comment above `beforeCreateInvitation`) still argues that `organization_admin` is kept read-only on the assignment tables. That premise is retired here, and the code under the comment is still right. - **Picker defect, pre-existing.** The tenant-level picker (`describeDelegableScope` for a platform administrator) also offers the reserved identity positions, which the write path refuses. This PR excludes them only on the organization-level branch. - **`docs/design/permission-model.md` §10** still describes delegated administration only, as the spec seat noted on this card. Not edited here. - **Possible gap in D12's delegated walk, read from code only.** It does not count the position's own-name set, which the resolver also grants (`setsBoundToPosition`, noted on this card by the spec seat). Not reproduced; the delegated path is unchanged here per the dispatch. --- _Generated by [Claude Code](https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b114ed commit b27f8de

13 files changed

Lines changed: 1220 additions & 82 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security): organization administrators assign positions and permission sets to their own members, inside the organization boundary
6+
7+
Clause-②: no
8+
9+
ADR-0090 D12 (as amended for ADR-0131 D3) names three kinds of administrator. This release enforces the second one, the organization administrator: a holder of `organization_admin` or `organization_admin_no_bypass` for the active organization who is not a platform administrator.
10+
11+
- **What an organization administrator may now do.** Create and remove `sys_user_position` and `sys_user_permission_set` rows of its own organization, for that organization's members. Every permission set the row distributes must be inside the organization boundary: the granted set, or each set the assigned position carries (including the set of the position's own name). A set is inside when its `objects['*']` entry carries neither `viewAllRecords` nor `modifyAllRecords`, it has no `adminScope`, and every name in its `systemPermissions` is a capability declared `scope: 'org'`. `admin_full_access`, both organization-administrator sets and the three MCP agent ceilings are outside by name. An assignment row is never edited in place: remove it and create the one you mean. A row inserted with no organization lands in the caller's organization.
12+
- **What is refused, and how.** An out-of-boundary write answers `403 PERMISSION_DENIED`, and the message names the set and the condition it failed. The same refusal covers a row of another organization, removal of an organization-less row, a business unit of another organization, an edit in place and a filter delete. Assigning a user who is not a member of the organization is refused `400 VALIDATION_FAILED` as before, for every writer. So is assigning a built-in identity position (`platform_admin`, `org_owner`, `org_admin`, `org_member`). Organization membership rows stay platform-administrator-only.
13+
- **Overrides.** `organization_admin`, and so `organization_admin_no_bypass`, now carries `allowCreate` and `allowDelete` on both assignment tables. `allowEdit` stays `false`.
14+
- **The two doors.** `GET /security/my-delegable-scope` reports an organization administrator with `isTenantAdmin: false`, every unit of its organization as placeable, and only in-boundary positions as assignable. The walled `organization_admin` was reported as tenant-level before. Explaining another user's access now admits an organization administrator explaining a member of its own organization.
15+
- **Unchanged.** Platform administrators keep today's behaviour. Delegated (`adminScope`) administrators keep D12's subtree, allowlist and strict containment. Everyone else is denied as before. No spec key, no endpoint and no membership-model change.

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4009,6 +4009,82 @@
40094009
"ref": "#24632"
40104010
}
40114011
]
4012+
},
4013+
{
4014+
"id": "access-security.org-admin-assignment-boundary",
4015+
"title": "An organization administrator creates and removes its own members' position and permission-set assignments inside the organization boundary, in all three tenancy postures; an out-of-boundary set answers 403 naming the set and the condition",
4016+
"since": "v18",
4017+
"status": "active",
4018+
"revision": 1,
4019+
"priority": "P1",
4020+
"surface": "api",
4021+
"personas": [
4022+
"a platform administrator (admin_full_access)",
4023+
"an organization administrator under a walled posture (organization_admin)",
4024+
"an organization administrator under a wall-less posture (organization_admin_no_bypass)",
4025+
"a member (member_default)"
4026+
],
4027+
"fixtures": {
4028+
"app": "none — the real-stack pin boots ObjectQL over SQLite with the SecurityPlugin per posture",
4029+
"requires": [
4030+
"an in-boundary permission set (no objects['*'] superuser bit, no adminScope, only scope 'org' capabilities) and a position carrying it",
4031+
"a position carrying a mixed bundle (an in-boundary set plus admin_full_access)",
4032+
"two organizations, with the target user a member of the administrator's organization only"
4033+
]
4034+
},
4035+
"steps": [
4036+
"for each posture (single, group, isolated) and each persona, insert a sys_user_position and a sys_user_permission_set row for a member, with an in-boundary and an out-of-boundary set",
4037+
"for the same cells, delete a system-seeded row of the administrator's organization by id",
4038+
"read GET /security/my-delegable-scope and explain another member's access as each organization administrator"
4039+
],
4040+
"acceptance": [
4041+
{
4042+
"clause": "the three-posture matrix passes at the run subject sha (RUNNER rule 6: cite the pin's output, never re-derive by hand)",
4043+
"oracle": "test",
4044+
"verify": "run each file named in automated.ref with its package's vitest (`pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 FILE`, never through a bare `--`); every named file reports ≥1 test and 0 failed. A file that reports 0 tests or is not found is NOT a pass — it is a stale ref, fix the item",
4045+
"evidence": "the vitest summary line per pin file, with the package name and the subject sha"
4046+
},
4047+
{
4048+
"clause": "both organization-administrator variants: in-boundary insert and delete on both tables succeed (the inserted row is stored in the administrator's organization); out-of-boundary insert and delete answer 403 PERMISSION_DENIED and the message names the set and the failed condition",
4049+
"oracle": "test",
4050+
"verify": "the per-cell cases of org-admin-assignment-boundary.posture.test.ts, three postures",
4051+
"evidence": "the case names and their pass lines"
4052+
},
4053+
{
4054+
"clause": "the platform administrator is admitted in every cell (unchanged); the member is refused 403 in every cell (unchanged)",
4055+
"oracle": "test",
4056+
"verify": "the platform and member cells of the same file",
4057+
"evidence": "the case names and their pass lines"
4058+
},
4059+
{
4060+
"clause": "the delegable-scope report gives an organization administrator isTenantAdmin false and offers only in-boundary positions; explain admits it for a member of its organization and refuses it for anyone else",
4061+
"oracle": "test",
4062+
"verify": "the report/explain case of the same file, per posture",
4063+
"evidence": "the case names and their pass lines"
4064+
}
4065+
],
4066+
"negative": [
4067+
"an organization administrator's assignment of a member of another organization lands — FAIL (expected 400 VALIDATION_FAILED from the grant-holder membership hooks)",
4068+
"an organization administrator edits an assignment row in place — FAIL (expected 403)",
4069+
"an organization administrator assigns admin_full_access, an adminScope set, a platform-capability set, either organization-administrator set or an agent ceiling, to anyone including itself — FAIL (expected 403 naming the set)"
4070+
],
4071+
"automated": {
4072+
"kind": "e2e",
4073+
"ref": "packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts + packages/plugins/plugin-security/src/delegated-admin-gate.test.ts (the organization-administrator describe) + packages/plugins/plugin-security/src/objects/rbac-objects.test.ts (the boundary pin over the shipped sets)"
4074+
},
4075+
"source": [
4076+
"packages/plugins/plugin-security/src/delegated-admin-gate.ts#organizationAdminFailure",
4077+
"packages/plugins/plugin-security/src/objects/default-permission-sets.ts",
4078+
"docs/adr/0090-permission-model-v2-concept-convergence.md (D12, amended 2026-10-17)"
4079+
],
4080+
"history": [
4081+
{
4082+
"revision": 1,
4083+
"date": "2026-10-20",
4084+
"change": "initial — the organization administrator's assignment boundary (ADR-0090 D12 as amended 2026-10-17), run once on the PR head through the automated pins",
4085+
"ref": "#24136"
4086+
}
4087+
]
40124088
}
40134089
]
40144090
}

‎packages/plugins/plugin-security/src/__tests__/security-catalog.testkit.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ type Definition = Record<string, unknown>;
2323
export interface TestSecurityCatalog {
2424
positions?: readonly Definition[];
2525
permissions?: readonly Definition[];
26+
capabilities?: readonly Definition[];
2627
}
2728

2829
/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */
2930
export function bindTestSecurityCatalog<T extends object>(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T {
3031
const items = (type: string): readonly Definition[] => {
3132
const c = typeof catalog === 'function' ? catalog() : catalog;
32-
return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? [];
33+
return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : type === 'capability' ? c.capabilities : undefined) ?? [];
3334
};
3435
bindSecurityCatalogReader(engine, createSecurityCatalogReader({
3536
registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false },

‎packages/plugins/plugin-security/src/auto-org-admin-grant.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,17 @@
3434
* `bootstrapPlatformAdmin` (which does the analogous thing for
3535
* platform admins).
3636
*
37-
* **Anti-escalation:** `organization_admin` itself (declared in
38-
* `platform-objects/src/security/default-permission-sets.ts`) is
39-
* deliberately read-only on the global RBAC tables
40-
* (`sys_permission_set`, `sys_user_permission_set`, `sys_position`, …),
41-
* so a freshly-granted org admin cannot rebind themselves to
42-
* `admin_full_access`.
37+
* **Anti-escalation — the organization boundary** (ADR-0090 D12, amended
38+
* 2026-10-17): the set granted here (declared in
39+
* `objects/default-permission-sets.ts`) creates and removes its organization's
40+
* `sys_user_position` / `sys_user_permission_set` rows only inside the
41+
* boundary the delegated-administration gate enforces — every distributed set
42+
* free of the `'*'` superuser bits and of `adminScope`, with only
43+
* `scope: 'org'` capabilities — and `admin_full_access`, the platform sets,
44+
* both org-admin variants and the agent ceilings stay outside it. So a
45+
* freshly-granted org admin cannot rebind itself (or anyone) to
46+
* `admin_full_access`, and cannot copy its own administrator status: that is
47+
* conferred here, by membership grade, and nowhere else.
4348
*/
4449

4550
import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec';

0 commit comments

Comments
 (0)