Repository navigation
Commit b27f8de
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
File tree
- .changeset
- docs/qa/platform-checklist/areas
- packages/plugins/plugin-security/src
- __tests__
- objects
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4009 | 4009 | | |
4010 | 4010 | | |
4011 | 4011 | | |
| 4012 | + | |
| 4013 | + | |
| 4014 | + | |
| 4015 | + | |
| 4016 | + | |
| 4017 | + | |
| 4018 | + | |
| 4019 | + | |
| 4020 | + | |
| 4021 | + | |
| 4022 | + | |
| 4023 | + | |
| 4024 | + | |
| 4025 | + | |
| 4026 | + | |
| 4027 | + | |
| 4028 | + | |
| 4029 | + | |
| 4030 | + | |
| 4031 | + | |
| 4032 | + | |
| 4033 | + | |
| 4034 | + | |
| 4035 | + | |
| 4036 | + | |
| 4037 | + | |
| 4038 | + | |
| 4039 | + | |
| 4040 | + | |
| 4041 | + | |
| 4042 | + | |
| 4043 | + | |
| 4044 | + | |
| 4045 | + | |
| 4046 | + | |
| 4047 | + | |
| 4048 | + | |
| 4049 | + | |
| 4050 | + | |
| 4051 | + | |
| 4052 | + | |
| 4053 | + | |
| 4054 | + | |
| 4055 | + | |
| 4056 | + | |
| 4057 | + | |
| 4058 | + | |
| 4059 | + | |
| 4060 | + | |
| 4061 | + | |
| 4062 | + | |
| 4063 | + | |
| 4064 | + | |
| 4065 | + | |
| 4066 | + | |
| 4067 | + | |
| 4068 | + | |
| 4069 | + | |
| 4070 | + | |
| 4071 | + | |
| 4072 | + | |
| 4073 | + | |
| 4074 | + | |
| 4075 | + | |
| 4076 | + | |
| 4077 | + | |
| 4078 | + | |
| 4079 | + | |
| 4080 | + | |
| 4081 | + | |
| 4082 | + | |
| 4083 | + | |
| 4084 | + | |
| 4085 | + | |
| 4086 | + | |
| 4087 | + | |
4012 | 4088 | | |
4013 | 4089 | | |
4014 | 4090 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
26 | 27 | | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
31 | 32 | | |
32 | | - | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
43 | 48 | | |
44 | 49 | | |
45 | 50 | | |
| |||
0 commit comments