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
15 changes: 15 additions & 0 deletions .changeset/24136-org-admin-assignment-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-security': minor
---

feat(plugin-security): organization administrators assign positions and permission sets to their own members, inside the organization boundary

Clause-②: no

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.

- **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.
- **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.
- **Overrides.** `organization_admin`, and so `organization_admin_no_bypass`, now carries `allowCreate` and `allowDelete` on both assignment tables. `allowEdit` stays `false`.
- **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.
- **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.
76 changes: 76 additions & 0 deletions docs/qa/platform-checklist/areas/access-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -3997,6 +3997,82 @@
"ref": "#24632"
}
]
},
{
"id": "access-security.org-admin-assignment-boundary",
"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",
"since": "v18",
"status": "active",
"revision": 1,
"priority": "P1",
"surface": "api",
"personas": [
"a platform administrator (admin_full_access)",
"an organization administrator under a walled posture (organization_admin)",
"an organization administrator under a wall-less posture (organization_admin_no_bypass)",
"a member (member_default)"
],
"fixtures": {
"app": "none — the real-stack pin boots ObjectQL over SQLite with the SecurityPlugin per posture",
"requires": [
"an in-boundary permission set (no objects['*'] superuser bit, no adminScope, only scope 'org' capabilities) and a position carrying it",
"a position carrying a mixed bundle (an in-boundary set plus admin_full_access)",
"two organizations, with the target user a member of the administrator's organization only"
]
},
"steps": [
"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",
"for the same cells, delete a system-seeded row of the administrator's organization by id",
"read GET /security/my-delegable-scope and explain another member's access as each organization administrator"
],
"acceptance": [
{
"clause": "the three-posture matrix passes at the run subject sha (RUNNER rule 6: cite the pin's output, never re-derive by hand)",
"oracle": "test",
"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",
"evidence": "the vitest summary line per pin file, with the package name and the subject sha"
},
{
"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",
"oracle": "test",
"verify": "the per-cell cases of org-admin-assignment-boundary.posture.test.ts, three postures",
"evidence": "the case names and their pass lines"
},
{
"clause": "the platform administrator is admitted in every cell (unchanged); the member is refused 403 in every cell (unchanged)",
"oracle": "test",
"verify": "the platform and member cells of the same file",
"evidence": "the case names and their pass lines"
},
{
"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",
"oracle": "test",
"verify": "the report/explain case of the same file, per posture",
"evidence": "the case names and their pass lines"
}
],
"negative": [
"an organization administrator's assignment of a member of another organization lands — FAIL (expected 400 VALIDATION_FAILED from the grant-holder membership hooks)",
"an organization administrator edits an assignment row in place — FAIL (expected 403)",
"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)"
],
"automated": {
"kind": "e2e",
"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)"
},
"source": [
"packages/plugins/plugin-security/src/delegated-admin-gate.ts#organizationAdminFailure",
"packages/plugins/plugin-security/src/objects/default-permission-sets.ts",
"docs/adr/0090-permission-model-v2-concept-convergence.md (D12, amended 2026-10-17)"
],
"history": [
{
"revision": 1,
"date": "2026-10-20",
"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",
"ref": "#24136"
}
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ type Definition = Record<string, unknown>;
export interface TestSecurityCatalog {
positions?: readonly Definition[];
permissions?: readonly Definition[];
capabilities?: readonly Definition[];
}

/** Bind a catalog over these definitions (read afresh per lookup when given a function); returns `engine`. */
export function bindTestSecurityCatalog<T extends object>(engine: T, catalog: TestSecurityCatalog | (() => TestSecurityCatalog)): T {
const items = (type: string): readonly Definition[] => {
const c = typeof catalog === 'function' ? catalog() : catalog;
return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : undefined) ?? [];
return (type === 'position' ? c.positions : type === 'permission' ? c.permissions : type === 'capability' ? c.capabilities : undefined) ?? [];
};
bindSecurityCatalogReader(engine, createSecurityCatalogReader({
registry: { getItem: (type, name) => items(type).find((d) => d.name === name), listItems: items, isPackageDisabled: () => false },
Expand Down
17 changes: 11 additions & 6 deletions packages/plugins/plugin-security/src/auto-org-admin-grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,17 @@
* `bootstrapPlatformAdmin` (which does the analogous thing for
* platform admins).
*
* **Anti-escalation:** `organization_admin` itself (declared in
* `platform-objects/src/security/default-permission-sets.ts`) is
* deliberately read-only on the global RBAC tables
* (`sys_permission_set`, `sys_user_permission_set`, `sys_position`, …),
* so a freshly-granted org admin cannot rebind themselves to
* `admin_full_access`.
* **Anti-escalation — the organization boundary** (ADR-0090 D12, amended
* 2026-10-17): the set granted here (declared in
* `objects/default-permission-sets.ts`) creates and removes its organization's
* `sys_user_position` / `sys_user_permission_set` rows only inside the
* boundary the delegated-administration gate enforces — every distributed set
* free of the `'*'` superuser bits and of `adminScope`, with only
* `scope: 'org'` capabilities — and `admin_full_access`, the platform sets,
* both org-admin variants and the agent ceilings stay outside it. So a
* freshly-granted org admin cannot rebind itself (or anyone) to
* `admin_full_access`, and cannot copy its own administrator status: that is
* conferred here, by membership grade, and nowhere else.
*/

import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec';
Expand Down
Loading
Loading