Skip to content

feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary - #24732

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24136-org-admin-assignment-boundary
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24136-org-admin-assignment-boundary

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 security: the effective permission map folds a super-user '*' over the same set's narrower explicit entries — a walled org admin gets can('sys_position','edit') true where enforcement refuses #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

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

  • security: the 18 migration text says organization admins assign positions and permission sets to their members; the shipped organization_admin sets are read-only on both assignment objects and the server refuses #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

…D12 organization boundary

The delegated-administration gate gains its organization-level branch
(ADR-0090 D12 as amended 2026-10-17): a holder of organization_admin /
organization_admin_no_bypass who is not tenant-level creates and removes
sys_user_position / sys_user_permission_set rows of its own organization
when every distributed set has no superuser wildcard bit, no adminScope
and only scope 'org' capabilities, and is not one of the sets the ADR
keeps outside by name. The overrides open create and delete on both
assignment tables; explain and the delegable-scope report learn the
organization administrator.

Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT
Co-authored-by: Claude <noreply@anthropic.com>
…n, and three-posture real-stack acceptance

Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT
Co-authored-by: Claude <noreply@anthropic.com>
…ent boundary; changeset

Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT
Co-authored-by: Claude <noreply@anthropic.com>
…he organization boundary overrides

Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT
Co-authored-by: Claude <noreply@anthropic.com>
…WHERE combinators it does not implement

Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 23 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/auto-org-admin-grant.ts, packages/plugins/plugin-security/src/invitation-placement.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0904d856e31fd065c71af725cf3ad4ad0363dc76.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/auto-org-admin-grant.ts, packages/plugins/plugin-security/src/invitation-placement.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0904d856e31fd065c71af725cf3ad4ad0363dc76 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6f5d40f4a8837caedc6a9234cbadd56ddb447be9 — the merge of head 20eb5f1f6d24c743c27ab7dcceb52707db7802bd into base 0904d856e31fd065c71af725cf3ad4ad0363dc76, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f5d40f4a8837caedc6a9234cbadd56ddb447be9 && git checkout 6f5d40f4a8837caedc6a9234cbadd56ddb447be9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0904d856e31fd065c71af725cf3ad4ad0363dc76 20eb5f1f6d24c743c27ab7dcceb52707db7802bd && git checkout -B drift-repro 0904d856e31fd065c71af725cf3ad4ad0363dc76 && git merge --no-ff 20eb5f1f6d24c743c27ab7dcceb52707db7802bd

node scripts/docs-audit/affected-docs.mjs --json 0904d856e31fd065c71af725cf3ad4ad0363dc76

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0904d856e31fd065c71af725cf3ad4ad0363dc76 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T03:18Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 0904d856e3, not against the report (os-dev-report 6282206380 on #24136). Disclosure read first: the PR body, the changeset and the report state the boundary and its refusals; none gives a recipe against any door. Head under review:

20eb5f1f6d24c743c27ab7dcceb52707db7802bd
  1. Shape. Draft, base main, first line Fixes #24136, line-start Clause-②: no in the body and the changeset. The body names security: the effective permission map folds a super-user '*' over the same set's narrower explicit entries — a walled org admin gets can('sys_position','edit') true where enforcement refuses #20136, security: the 18 migration text says organization admins assign positions and permission sets to their members; the shipped organization_admin sets are read-only on both assignment objects and the server refuses #23960, [Decision] The organization administrator's assignment boundary: an assignable set with a wildcard read and no row scope reaches other organizations' SCIM rows in a deployment with no platform baseline #24164 and platform-objects: the identity objects declare their default row scope (the literals the shipped sets copy today), and the registry refuses an extend contributor that loosens a platform object's default (#24164 ruling E′) #24341 with no keyword beside any of them; the only closing keyword in the body is the first line's. The changeset is @objectstack/plugin-security minor (ruling item 3), and the package is published (files[] in its package.json). Each changeset sentence was read against the diff: the three conditions, the by-name exclusions, create-and-remove only, the stamping of an organization-less insert, the 403 / 400 split, allowCreate / allowDelete true with allowEdit false, and the two doors. 13 files, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean; main moved by fix(plugin-security): explain's record-grained write verdict composes the by-id write gate's authored-row-write deferral #24713 and fix(plugin-security): the platform-admin promotion WITHHELD warning says what 18.0 supports for a v17 database #24718 since the base, neither in a region this PR edits.

  2. The ruling, measured by the seat.

    • The three conditions in organizationBoundaryFailure are the ADR-0090 D12 note's (a), (b) and (c), read against docs/adr/0090-permission-model-v2-concept-convergence.md:535–600 on main. Condition (c) reads the security catalog's capability kind, which the catalog serves (packages/core/src/security/security-catalog.ts:106). The by-name list is the ADR's: admin_full_access, both organization-administrator sets and the three D10 agent ceilings. There is no fourth condition (E′ on [Decision] The organization administrator's assignment boundary: an assignable set with a wildcard read and no row scope reaches other organizations' SCIM rows in a deployment with no platform baseline #24164, item 9).
    • Holding an organization-administrator set means administering the active organization only: the resolver applies a stored grant when its organization is null or the active one (grantAppliesInTenant, packages/core/src/security/resolve-authz-context.ts:861), and auto-org-admin-grant.ts always writes the grant with the membership's organization.
    • "Tenant-level" moves only for the two organization-administrator sets. Any other wildcard holder keeps today's pass-through, as the dispatch requires. The gate governs exactly sys_user_position, sys_user_permission_set and sys_member; on sys_member an organization administrator is now refused at the gate, on a path the file itself records as unreachable today.
    • Removing an organization-administrator grant is refused by name, so one administrator cannot demote another; that status still comes from the membership grade alone.
  3. Refusals and pins. Every refusal case asserts the ADR-0112 envelope as a pair ([status, code] equal to [403, 'PERMISSION_DENIED'] or [400, 'VALIDATION_FAILED']) in the real-stack posture file. The gate unit file asserts code and status through one helper. Six ablations are reported, each red and restored with blob equal to HEAD. Ablation 6 (the walled administrator read as tenant-level again) turns all four boundary files red, so the posture matrix carries load.

  4. Gates. At ACCEPT, every check-run on this head is success or an expected skip (Build Docs, Console Pin Gate, Packed-tarball smoke, Temporal Conformance, Dogfood Verify CLI, Dogfood Regression Gate). This includes TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core and Governed Surface Queue Guard. Locally, 70 of 71 derived families exited 0 at this head. check-plugin-teardown-shape --self-test is NOT MEASURED locally (shallow clone) and is covered by CI's lint lane.

  5. Accepted deviations. The claim surface is amended to cover three items:

    No other open PR touches these files. SharingServicePlugin is not composed in the posture file; the assignment tables carry no sharing.

  6. Done-when residuals.

  7. Out-of-scope dispositions.

  8. Landing clause. No contract review is owed. The change is a runtime permission change ruled by the maintainer (B on security: the 18 migration text says organization admins assign positions and permission sets to their members; the shipped organization_admin sets are read-only on both assignment objects and the server refuses #23960), declared Clause-②: no on the ruling's own words. It touches no packages/spec text, no governed surface and no published schema. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 03:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 03:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit b27f8de Oct 20, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24136-org-admin-assignment-boundary branch October 20, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants