Repository navigation
feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary - #24732
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
席位核对 · ACCEPT ·
|
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-securityenforced 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_adminororganization_admin_no_bypassfor its active organization and is not a platform administrator. It may create and removesys_user_positionandsys_user_permission_setrows 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:objects['*']entry carries neitherviewAllRecordsnormodifyAllRecords;adminScope;systemPermissionsis a capability the security catalog declaresscope: '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: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_FAILEDby 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 refused400by the existing object rule, for every writer.Changes (all in
@objectstack/plugin-security)delegated-admin-gate.ts). "Tenant-level" now means a superuser wildcard from any set other than the two organization-administrator sets. The walledorganization_adminwas read as tenant-level before, and the ADR note says it is organization-level. A caller holding an organization-administrator set is judged byorganizationAdminFailureafter the self-delegation path. An organization administrator that also holds anadminScopefalls 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 exportedisTenantAdminis unchanged.objects/default-permission-sets.ts).organization_admingetsallowCreate: trueandallowDelete: trueon both assignment tables, andallowEditstaysfalse.deriveWallLessOrgAdmincarries this to the no-bypass variant unchanged. The anti-escalation comment now states the boundary.describeDelegableScope, and through itGET /security/my-delegable-scope, reports an organization administrator withisTenantAdmin: 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.explainAccessForCalleradmits an organization administrator explaining a member of its own organization (organizationAdminCoversUser).DelegableScopeshape.invitation-placement.tsandauto-org-admin-grant.tsnow state the boundary as the safety premise instead of the read-only overrides.minorchangeset for@objectstack/plugin-security(ruling item 3). There is nopackages/specchange, 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 assertscodeandstatus.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 assignadmin_full_access, anadminScopeset, a platform-capability set or either organization-administrator set, to anyone, itself included.member_default,viewer_readonlyand 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 carryingadmin_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 realObjectQL, a real SQLiteSqlDriverand the realSecurityPlugin: gate, overrides, CRUD and membership hooks in production order.single,groupandisolated; platform administrator,organization_admin,organization_admin_no_bypassand 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.403 PERMISSION_DENIEDnamingadmin_full_accessand the by-name condition. The platform administrator is admitted in every cell, and the member is refused in every cell.SharingServicePluginis 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 getscan('sys_position','edit')true where enforcement refuses #20136 parity case followed the overrides. Edit stays refused, create and delete are now granted, andcan()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)
delegated-admin-gate.tsorganizationBoundaryFailureadmits every setallowEdit: falsestill refuses: defence in depth)organization_adminread as tenant-level again (the oldisTenantAdminshort-circuit)Every leg ran through
scripts/ablation-replace.mjs: anchor 1 → 0, the blob moved, and the restore was verified asblob == HEAD,git diff HEADempty. The tests importsrcdirectly, so nodistwas involved.Verification at
20eb5f1f(the head this PR opens with)pnpm --filter '@objectstack/plugin-security^...' build:VERDICT command-exit 0.@objectstack/plugin-securityfull 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 ischeck-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.--ranreconciliation: 71 derived, 70 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 unrun.pnpm check:where-matcherreddened the first gate run, because the new test double read$-combinators as field names. It now refuses them, and the gate passes.test:reporuns in CI.Rulings carried
Clause-②: no,minor, and the denylist fallback as the ADR names it (the three agent ceilings).extendcontributor that loosens a platform object's default (#24164 ruling E′) #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)
adminScope. If neither admits the write, the refusal is the delegated one.Acceptance notes
my-delegable-scopereports placeable units and assignable positions. Expect no console code; a measured gap there is adomain:uicard.packages/plugins/plugin-auth/src/auth-manager.ts(the comment abovebeforeCreateInvitation) still argues thatorganization_adminis kept read-only on the assignment tables. That premise is retired here, and the code under the comment is still right.describeDelegableScopefor 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.setsBoundToPosition, noted on this card by the spec seat). Not reproduced; the delegated path is unchanged here per the dispatch.Generated by Claude Code