Repository navigation
fix(plugin-security)!: a delegate can neither capture nor evict a direct grant beyond its scope - #31634
Conversation
…ect grant beyond its subtree An update that points a direct permission-set grant at another user is judged on both holders it touches: the user it gives the grant to and the user it takes the grant from, each through the caller's own read, with the same refusal (ADR-0090 D12). Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…ect grant beyond its subtree Pins the rule on the fake engine and on the posture rig in the single, group and isolated postures: a move of a stored direct grant is refused when either holder is outside the subtree or unreadable to the caller, with the same answer at either end; moves between two readable holders inside the subtree, and a tenant-level administrator's moves, are admitted; an organization administrator's answer is unchanged. Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…ect grant beyond its subtree The changeset for the narrowing, and the delegated-administration page's subtree-anchoring rule now names both holders of a moved direct grant. Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…grant-repoint-both-holders Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…grant-repoint-both-holders Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 c610d70032b04f5a3091e96bd39625e58e575df4 && git checkout c610d70032b04f5a3091e96bd39625e58e575df4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 06060c61c572f833f19497a6e84838c52a923166 3890ac0699464c3b5d72dfd7e19117e16e3a77be && git checkout -B drift-repro 06060c61c572f833f19497a6e84838c52a923166 && git merge --no-ff 3890ac0699464c3b5d72dfd7e19117e16e3a77be
node scripts/docs-audit/affected-docs.mjs --json 06060c61c572f833f19497a6e84838c52a923166
|
…grant-repoint-both-holders Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…ect grant beyond its scope, on the set axis too An update that re-names a stored direct grant's permission set is also judged on the set it takes away, exactly as the removal of that grant judges it: the same reasons, sentences and details, the stored set named only when the caller reads it (ADR-0090 D12; ruling of record 6649064874). Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…n the set it takes away, in three postures Pins on the posture rig in the single, group and isolated postures: a re-name FROM a set outside the allowlist is refused with the answer the removal of that grant gets, at the gate and through the write door; a delegate that cannot read the stored grant is told the same without the set's name; a re-name TO a set outside is refused as before; re-names between two allowlisted sets, and a tenant-level administrator's, land. Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…e, so no pin boots more than two The refusal pins of the moved-grant describe move nothing, so each of the four subject states boots one rig that serves them all (each pin re-reads both grants); the CONTROLs that move a grant boot their own, one end per pin. No assertion is dropped: the cross-state "one answer" is now each state's answer against the one literal answer, and the organization-administrator CONTROL runs in all four states. The heaviest pin booted eight rigs and timed out under CI load. The changeset and the delegated-administration page state the rule on the set axis too: a delegate can neither capture nor evict a direct grant beyond its scope. Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
…nned whole, in every subject state The FROM-end pin now holds the write door's answer to the one literal answer in full (status, code, class, message and details), as the earlier cross-state comparison held it, instead of three of its fields. Claude-Session: https://claude.ai/code/session_017xRBcT1w3zpekijraHJE4f Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f 席位核对ACCEPT at
Implemented-by: os-dev (security#1 order, |
Clause-②: no (narrowing) — an edit that points a direct permission-set grant at another user is judged on both holders it touches, the user it takes the grant from and the user it gives the grant to, and an edit that re-names a stored grant's set is judged on the set it takes away as well as the set it grants, so a delegated administrator no longer moves a grant away from a holder outside its subtree or from a holder it cannot read, nor re-names a stored grant of a set outside its allowlist. Nothing is widened: no export, error code or status is added.
Metadata-protocol: none
Skills-docs: updated here —
content/docs/permissions/delegated-administration.mdxstates it in two rows ("Subtree anchoring" for the holders, "Allowlist, no self-escalation" for the set); noskills/**file teaches editing a direct grant (skills/objectstack-data/rules/security.mdsays a delegate writes a grant only "for that set and user", which stays true).维护者速读
sys_user_permission_set)时,委派门现在判断这次编辑碰到的每个持有人和每个权限集:改指向另一个用户时,原持有人和新持有人两端都要在委派子树内且调用者读得到;改名为另一个权限集时,被拿走的原权限集也按「删除这条授权」的标准判断(白名单、安全目录、严格包含)。任一不过即拒。sys_user_position同时判断前像与新值的做法一致:删不掉的授权,也不能靠编辑挪走。属于安全加固,细节归 security#1 席。403 DELEGATION_SCOPE_VIOLATION)。租户级管理员照旧可改;组织管理员的权利本来就只有新建和删除、不含原地编辑,答案不变。回滚 = revert 本 PR。packages/plugins/plugin-security/src/delegated-admin-gate.ts的assertDirectGrantWrite(fromHolder与stored两处判断及refusedSet的细节选取,风险 hunk)、directGrantSet/grantOwnSet;②delegated-admin-own-name-set.posture.test.ts末尾两个三形态 describe;③delegated-admin-gate.test.ts末尾两个 describe。pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/delegated-admin-gate.test.ts src/delegated-admin-own-name-set.posture.test.ts,预期Test Files 2 passed (2)、Tests 383 passed (383)。What changes
Rule. A delegate can neither capture nor evict a direct grant beyond its scope. A delegated write on a direct permission-set grant is judged on every holder and every set it touches (ADR-0090 D12, which scopes direct set grants to "inside the subtree" and allowlists what a delegate may hand out).
assertDirectGrantWrite(packages/plugins/plugin-security/src/delegated-admin-gate.ts): when an update changes the grant's holder, the stored holder is judged exactly as the holder the write names — through the caller's own read (targetUserUnits), inside the subtree only through one of its business units — with the same reason, class, sentence and details. Mirrors the sibling door,assertAssignmentWrite, whose docblock reads "Pre-image (update/delete) must also lie inside the subtree — a delegate can neither capture nor evict assignments beyond it." A stored grant that names no holder is moved only by a tenant-level administrator, as an unanchored assignment is modified only by one.directGrantSet/ the newgrantOwnSet: when an update re-names the grant'spermission_set, the set it takes away is judged exactly as the removal of that grant judges it — the allowlist, the security catalog and strict containment — with the same reasons, sentences and details (the refusal'spermissionSetis the set it is about), beside the set it re-names the grant to, judged as before.directGrantSet's docblock says the stored set is judged too.content/docs/permissions/delegated-administration.mdx: the "Subtree anchoring" row names both holders of a moved grant; the "Allowlist, no self-escalation" row names the set a re-name takes away..changeset/delegated-grant-move-judges-both-holders.md:patch,fix(plugin-security)!: a delegate can neither capture nor evict a direct grant beyond its scope, a rule-level BREAKING paragraph whose "Who moves" reads "A delegate can no longer move a direct grant away from a holder outside its subtree, nor re-name a stored grant of a set outside its allowlist.", and the ADR-0087 markernot-required (no-migration-prescription), spelled as in.changeset/delegated-unreadable-user-outside-subtree.md.Docblocks of record touched — quoted. One sentence of
judgesTargetUserThroughCallerRead's docblock ([ADR-0090 D12; ruling on #26059, Q3]) gains the new holder;directGrantSet's[ADR-0131 D4]docblock gains the stored set; the rest of every docblock is kept as written.Untouched and still governing the new branches: the inline
[ADR-0090 D12; ruling on #26059, Q3]comment ("the refusal is one reason, one class, one sentence and one set of details for all of them") and[ruling of record 6649064874] Named only when the caller wrote or reads it.Ruling #26059 Q3, the scope-violation class (ruling of record 6416233738, item 3, verbatim):
A holder outside the subtree at the end an update takes the grant from is a target user outside the subtree, and a set an update takes away outside the allowlist is a set not in the allowlist, so both answer that class; a stored grant that names no holder, a stored set the catalog does not hold, and a stored set the caller's scope does not strictly contain are not the caller's own scope and keep
PERMISSION_DENIED, as their removal does.Tests
Named by the rule, on both rigs:
delegated-admin-gate.test.ts(fake engine), two new top-level describes. Holders (8 pins): a move FROM a holder outside TO one inside is refused for each of the four target states (no user, no membership, a unit outside, a unit inside the caller cannot read) with one answer; FROM an unreadable in-subtree holder; TO a holder outside, as before; the same answer whichever end is outside (class, code, status, message, details and the REST envelope); CONTROL: a move between two readable in-subtree holders is admitted at either end; both holders are asked of the caller's own read under the write's own context; a holderless stored grant is moved only by a tenant admin; CONTROL: a tenant-level administrator moves the grant whichever end is outside and asks the read nothing. Sets (6 pins): a re-name FROM a set outside the allowlist TO an allowlisted one is refused with the answer the removal of that grant gets; the removal's other refusals hold for a re-name (a set not strictly contained, a name the catalog does not hold); a caller that cannot read the stored set gets the same refusal without its name, as the removal does; a re-name TO a set outside, as before; CONTROL: a re-name between two allowlisted sets is admitted; CONTROL: a tenant-level administrator re-names freely.delegated-admin-own-name-set.posture.test.ts(realObjectQL+ SQL driver +SecurityPlugin), in each ofsingle,group,isolated. Holders (24 pins per posture): per subject state, on one rig the state boots once (every pin there is a refusal and re-reads both grants to show nothing moved) — a move FROM the subject is refused with the one literal answer, whole (status, code, class, message and details), at the gate and through the write door, and the grant stays; a move TO the subject, as before; the same answer whichever end; a delegate that cannot read the stored grant gets the same refusal without the stored holder's name (ruling of record 6649064874); CONTROL: an organization administrator gets the answer it always got at either end (its right creates and removes a grant, never edits one in place). Then, booting their own rigs: CONTROL moves between two readable in-subtree holders land at either end; CONTROL: a tenant-level administrator still moves the grant FROM and TO a holder outside, through the write door. Sets (5 pins per posture): a re-name FROM a set outside the allowlist is refused with the removal's answer, at the gate and through the write door, and the grant keeps its set; a delegate that cannot read the stored grant gets the same refusal without the set's name, as the removal does; a re-name TO a set outside, as before; CONTROL: a re-name between two allowlisted sets lands; CONTROL: a tenant-level administrator re-names FROM and TO a set outside the allowlist.No posture pin boots more than two rigs; the heaviest one (eight boots) had timed out under CI load (
Test timed out in 5000ms, Test Core 7/8 on0dcdbb4712). No assertion was dropped: the cross-state "one answer" is each state's answer against the one literal answer, and the organization-administrator CONTROL now runs in all four states. The file's local wall fell fromtests 37.93stotests 26.32swith the set pins added.Ablations (one behaviour each, committed first,
node scripts/ablation-replace.mjsinside a trap restore, under the verify lock, on the final head3890ac0699):3793552a→1ff05ea9) →Tests 41 failed | 342 passed (383): exactly this rule's holder pins — 5 on the fake engine (FROM-end, unreadable holder, identity, both holders read, holderless) and 36 on the posture rig (FROM-end, identity, unnamed holder × 4 states × 3 postures); every CONTROL and every TO-end pin stayed green. Restored: blob == HEAD (3793552a),git diff HEADempty.3793552a→1b94cfd1) →Tests 9 failed | 374 passed (383): exactly this rule's set pins — 3 on the fake engine (FROM-end, the removal's other refusals, unnamed set) and 6 on the posture rig (FROM-end, unnamed set × 3 postures); every CONTROL and every TO-end pin stayed green. Restored: blob == HEAD (3793552a),git diff HEADempty.Both suites reach the gate by relative imports from
src/(nodist/on the path), so no rebuild sits between mutation and run.Acceptance notes
403 PERMISSION_DENIED, "'update' is not part of the right"). The pins hold it unchanged; the tenant-level administrator moves and re-names as before.user_idis required onsys_user_permission_set, so no write door stores one; the branch fails closed for a row a system write left so.plugin-security. feat(plugin-auth): a SCIM group push grants the ordinary permission set its group mapping names #31443 (the organization-boundary helper indelegated-admin-gate.ts) landed onmainwhile this branch was in flight; this branch merges it cleanly.Verification
Every reading below is on the final head
3890ac0699(this branch merged withorigin/main69244406b7), unless named.plugin-security test and typecheck
pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2(whole package, under the verify lock):VERDICT command-exit 0,Test Files 250 passed (250),Tests 6025 passed | 45 skipped (6070).pnpm --filter @objectstack/plugin-security run typecheck:VERDICT command-exit 0;check:test-typecheck: OK — … 0 file(s) / 0 error(s).delegated-admin-gate.test.tsTests 190 passed (190);delegated-admin-own-name-set.posture.test.tsTests 193 passed (193)(tests 30.33son3890ac0699,tests 26.32son38577d9ec8; it wastests 37.93sbefore the split).Derived gate families (dispatch-gates --commands, reconciled with --ran)
3890ac0699, after a fullpnpm build("Tasks: 119 successful, 119 total"); all 97 run, each exit code captured before any pipe: 97 × exit 0.check:type-check-debtfirst refused (exit 3: its closure build did not get a turn on the verify lock) and was re-run under the lock: "128/131 workspace packages type-checked", "--re-measure: OK — … 0 raw tsc error(s) total".check-adr-0087-registration --base origin/main(+--self-test),check-changeset-no-major --base origin/main,check-empty-changeset --base origin/main(+--self-test),check:changeset-gate-self-tests.dispatch-gates --ran: "97 derived famil(ies) accounted for — 97 run, 0 NOT-MEASURED (a DERIVED zero — all 97 recorded an exit code and none of them is 3)", 0 UNRUN.Dogfood battery (the narrowing arm's kit), on the final head
pnpm build, thenpnpm --filter @objectstack/dogfood testunder the verify lock, no shard and no path, head3890ac0699:VERDICT command-exit 1,Test Files 1 failed | 439 passed | 3 skipped (443),Tests 1 failed | 3640 passed | 41 skipped (3682). The one failure:test/package-commit-revert-path-package.dogfood.test.ts› "an uninstalled package's commit is refused 404 through its own revert and rollback path, and writes nothing" —Test timed out in 5000ms(7127 ms), on a box running this union and other seats' builds beside it. Not this change's: the file exercises the package commit revert path (ADR-0067), which no line here reaches; it passed in the full battery on38577d9ec8(Test Files 440 passed | 3 skipped (443),Tests 3641 passed | 41 skipped (3682)), whose runtime source is byte-identical to3890ac0699(they differ in one plugin-security test file); and the same file re-run alone on3890ac0699under the lock passes,Tests 12 passed (12). CI'sDogfood Regression Gateon3890ac0699was satisfied by its path filter — no dogfood shard ran on this PR ("expected attestations: 0") — so the battery above is the only dogfood run on this head.CI on the final head
3890ac0699: 50 check-runs, 38 success, 12 skipped, none other —Lint & Repo Gates,TypeScript Type Check,Test Core(8/8 shards),Build Core,Governed Surface Queue Guard,Test UI,Live E2E UI,Dogfood Regression Gate(filter-satisfied; no shard ran),Check Changesetall success; the UI lint, type-check and build lanes skipped by their path filters.