Repository navigation
fix(plugin-security): the delegated-admin gate counts a holding only in the organization the runtime grants it - #19866
Conversation
…in the organization the runtime grants it Self-delegation rule 4 (activeHoldings) and the binding blast radius (assignmentAnchorsOfPosition) read sys_user_position by position NAME under a bare system context, so another organization's same-named holding answered "you currently hold it", and another organization's same-named assignments refused a binding (outside subtree / over cap). Both now apply the runtime resolver's own rule (resolveAuthzContext step 4): a holding stamped for a different organization never counts; an organization-less holding counts in every organization. activeHoldings is keyed on the caller's organization; the blast radius on the bound sys_position row's organization, with the scope in the read's context so the cap counts only reachable holdings. An organization-less caller is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 186a69ac0165ea623f887a1bc437f1255072e6c0 && git checkout 186a69ac0165ea623f887a1bc437f1255072e6c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 48c91e9e460154716114c042e9bb92c05bd8dbfc 4cb682ce36423cdb1ad3c79ff5cad10e182db773 && git checkout -B drift-repro 48c91e9e460154716114c042e9bb92c05bd8dbfc && git merge --no-ff 4cb682ce36423cdb1ad3c79ff5cad10e182db773
node scripts/docs-audit/affected-docs.mjs --json 48c91e9e460154716114c042e9bb92c05bd8dbfc
|
Contract review (retroactive — PR landed as b940f32 before this record was written)Served-tier:
① Derived judgmentsThe changeset prose was checked claim by claim. Every factual claim is TRUE:
Clause-② value Two citation defects:
② Semver level
③ Boundary flagsZero Implemented-by: VERDICT: PASS Non-blocking defects:
Generated by Claude Code |
Closes #19860
Clause-②: no — scopes two existing reads in the delegated-admin gate to the organization the runtime grants in; no new key on a published payload (claim 5795172088, SKILL.md:524).
What changed
packages/plugins/plugin-security/src/delegated-admin-gate.ts— the two gate reads ofsys_user_positionthat were keyed by position NAME under a bare{ isSystem: true }context now count a holding only where the runtime grants it.activeHoldings(ADR-0091 D3 self-delegation rule 4) — takes the caller's organization and drops every holding stamped for a DIFFERENT organization before rule 4 (and rule 4b's anchor subtree) reads it. A user whose onlyfield_leadholding is in org B can no longer self-delegate org A's same-named, delegatablefield_lead.assignmentAnchorsOfPosition(ADR-0090 D12 binding blast radius) — measured, and it holds (see below). It is now keyed on the organization of the BOUNDsys_positionrow (read byposition_id), not on the caller's organization. The organization goes into the read's context, so the driver's tenant scope applies beforeBLAST_RADIUS_CAP, and the same rule is re-applied in process for a driver that does not scope.holdingTakesEffectIn(row, organizationId). An organization-less caller (singleposture) drops nothing, so its behaviour is unchanged, as fix(plugin-security): the delegated-admin gate resolves a scope's business-unit anchor inside the caller's own organization #19800 / fix(plugin-security): the delegated-admin gate resolves a position name inside the caller's own organization #19859 decided.Repair arm: B, "not another organization's row"
Chosen: (B). A holding stamped with a different organization never counts. An organization-less holding counts in every organization.
Evidence:
packages/core/src/security/resolve-authz-context.tsstep 4 readssys_user_positionbyuser_idunder a bare system context. It then runsconst org = ur.organization_id ?? null; if (org && tenantId && org !== tenantId) continue;. It keeps organization-less rows for ANY tenant, keeps rows stamped with the active tenant, and drops rows stamped with another tenant. With no tenant, it keeps everything. Arm (A) would refuse holdings the runtime grants. Arm (B) is the runtime's own rule, andholdingTakesEffectInspells it with the same truthiness: an emptyorganization_idcounts as organization-less.For the blast radius, the same rule applies to the bound row's organization. The runtime resolves
sys_positionby name through the active tenant (tryFind(..., tenantId), so its own row plus organization-less rows), then reads bindings byposition_id. The holders that a binding on row P re-composes are therefore the holdings that take effect in P's organization. Keying on the caller's organization instead would under-count when a caller binds against another organization's row id, so it was not used. An organization-less P reaches every organization, so nothing is dropped.Measurement:
assignmentAnchorsOfPosition(pre-fix tree, real ObjectQL + SqlDriver)field_leadis held inside the delegate's subtree, and org B's same-namedfield_leadis held atbu_0_sales_east. The binding in org A was refused withposition 'field_lead' is held in business unit 'bu_0_sales_east', outside the delegated subtree. That is over-refusal.position 'field_lead' has more than 500 assignments. That is a spurious overCap.Both are fixed in this PR with the same arm.
Tests
New file
packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts: a two-organization real-engine fixture, 12 tests. Refusal cases assert the ADR-0112 envelope (code: 'PERMISSION_DENIED',statusCode: 403) and then the message's first sentence.Witnesses. Each fails on the pre-fix source and passes with the fix:
Pins. These pass on both trees:
single-posture caller keeps counting every holding.Reverse verification, from committed
4cb682ce36: I restored the pre-fix gate source from0e90a8d1c5onto disk. On-disk proof: theholdingTakesEffectIncount is 0 and thepositionNameByIdcount is 2. The suite then ran red,Tests 4 failed | 8 passed (12), and the 4 failures are exactly the witnesses above. I restored withgit checkout HEAD -- PATHunder an EXIT trap. The blob hash equals theHEAD:blob44f891d329, andgit diff HEADis empty. With the fix:Tests 12 passed (12).Local runs on head
4cb682ce36:pnpm --filter @objectstack/plugin-security test:Test Files 120 passed (120),Tests 2289 passed (2289).pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new test file is compiled bytsconfig.test.json, confirmed with--listFiles.eslint --no-inline-config --format jsonon the 2 changed.tsfiles reports 2 files, 0 errors, 0 warnings. Population: both files are matched byeslint.config.mjs'spackages/**/*.{ts,...}blocks, and neither is ignored (0 "file ignored" warnings). Invariance: the config enables no type-aware linting (noparserOptions.project, per its own header), so this diff cannot move the verdict for any untouched file.node scripts/pm/dispatch-gates.mjs --commandsderived 62 families. 59 exited 0.check:dual-build-cjs-loads,check:i18nandcheck:type-check-debtexited 3 with PREREQUISITE NOT MET (they need whole-workspacedist/), so they are NOT MEASURED and left to CI.check-changeset-fixed,check:authz-resolver,check:tenant-chokepoint,check:error-code-casing,check:filter-alias-parity.Changeset:
patchfor@objectstack/plugin-security. Zero edits underpackages/specorcontent/docs/releases/**.Acceptance notes
These are observations only. None is filed and none is reproduced as a defect.
organization_id.SqlDriver.applyTenantScopekeeps only= :org OR IS NULL. The runtime resolver's truthiness check treats''as organization-less. The blast-radius read goes through the driver, so a''-stamped holding would be left out of the radius, while the runtime would grant it everywhere. The insert path normalises''to the tenant, so this needs a hand-written row. Not measured. Carrier: none.sys_positionrow id.positionByIdreads by id under a bare system context, so the gate does not ask whether a caller in org A may bind org B's row at all. After this PR, such a write is at least judged by org B's holders, which is the fail-closed direction. Not measured. Carrier: none.Generated by Claude Code