Repository navigation
fix(plugin-security): the delegated-admin gate resolves a scope's business-unit anchor inside the caller's own organization - #19800
Conversation
… anchor inside the caller's own organization
`sys_business_unit.name` carries no uniqueness — the object's only unique
index is `(code, organization_id)` — yet the delegated-admin gate looked a
scope's anchor up by name alone, under a bare `{ isSystem: true }` context
that carries no tenant. The engine passes a tenant to the driver only when
`execCtx.tenantId` is defined, and `SqlDriver.applyTenantScope` returns early
without one, so no layer scoped the read: with two organizations each holding
a unit called `sales`, a `limit: 1` read answered whichever id sorted first,
across the organization boundary.
The anchor read and the descendant walk now carry the caller's own
organization (`organizationId ?? tenantId`, the same spelling the
permission-set load already uses), and the candidates that come back are
reduced to the caller's own rows. Both arms are load-bearing: the driver's
compatibility arm deliberately also returns organization-less rows, and a
driver with no tenant scoping returns every organization's. Fail closed —
an anchor that resolves only in another organization approves nothing.
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
…tion crossing A real ObjectQL over a real SqlDriver, two organizations each holding a unit named `sales`, and ids ordered so the other organization's sort first. Pins both directions the unscoped by-name read broke at once: the org A delegate keeps its own subtree, a write anchored in org B is refused, and `describeDelegableScope` names no org B id. Controls: a single organization still resolves (dark), an anchor naming no unit approves nothing (firing), and an organization-less caller keeps the by-name answer. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
… an unscoping driver The companion half: a fake `ql` that ignores `context` entirely — the shape of a driver with no tenant scoping, and of the SQL driver's own deliberate organization-less compatibility arm — hands the gate every organization's rows, so the row-level selection is the only thing standing. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
… organization Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…binators it does not implement `check:where-matcher` reads a `$`-prefixed key handled as a field name as a silently-wrong matcher. The double now throws, matching the harness beside it. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 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 6efee75067c9d6dbeed8c0f9dedc18c836373cee && git checkout 6efee75067c9d6dbeed8c0f9dedc18c836373cee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fae870352ea59ddfb1c6dfd784bc6552cf158211 da621549c08a44602c01b003a6b1e8d7b46a1eee && git checkout -B drift-repro fae870352ea59ddfb1c6dfd784bc6552cf158211 && git merge --no-ff da621549c08a44602c01b003a6b1e8d7b46a1eee
node scripts/docs-audit/affected-docs.mjs --json fae870352ea59ddfb1c6dfd784bc6552cf158211
|
…in the organization the runtime grants it (objectstack-ai#19866) Closes objectstack-ai#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 of `sys_user_position` that 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 only `field_lead` holding is in org B can no longer self-delegate org A's same-named, delegatable `field_lead`. - **`assignmentAnchorsOfPosition` (ADR-0090 D12 binding blast radius)** — measured, and it holds (see below). It is now keyed on the organization of the BOUND `sys_position` row (read by `position_id`), not on the caller's organization. The organization goes into the read's context, so the driver's tenant scope applies before `BLAST_RADIUS_CAP`, and the same rule is re-applied in process for a driver that does not scope. - One shared predicate, `holdingTakesEffectIn(row, organizationId)`. An organization-less caller (`single` posture) drops nothing, so its behaviour is unchanged, as objectstack-ai#19800 / objectstack-ai#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.ts` step 4 reads `sys_user_position` by `user_id` under a bare system context. It then runs `const 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, and `holdingTakesEffectIn` spells it with the same truthiness: an empty `organization_id` counts as organization-less. For the blast radius, the same rule applies to the bound row's organization. The runtime resolves `sys_position` by name through the active tenant (`tryFind(..., tenantId)`, so its own row plus organization-less rows), then reads bindings by `position_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) - Org A's `field_lead` is held inside the delegate's subtree, and org B's same-named `field_lead` is held at `bu_0_sales_east`. The binding in org A was **refused** with `position 'field_lead' is held in business unit 'bu_0_sales_east', outside the delegated subtree`. That is over-refusal. - Org B has 501 same-named assignments. The binding in org A was **refused** with `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: - A user whose only holding is in org B cannot self-delegate org A's same-named position. In org B, the same delegation stands. - A direct holding in org B does not make org A's delegated-only holding re-delegatable. - Org B's same-named assignments do not refuse org A's binding (outside-subtree anchor). - Org B's same-named assignments do not push org A's binding over the cap. Pins. These pass on both trees: - A user holding the position in org A can self-delegate it. - An organization-less holding counts in org A (arm B). - An organization-less unanchored holding IS in org A's blast radius. - A binding against org B's row is still judged by org B's holders. - A `single`-posture caller keeps counting every holding. - The rule 4b anchor. It was already refused pre-fix by the caller-org subtree resolution, so it is a regression pin only. - A dark control. Reverse verification, from committed `4cb682ce36`: I restored the pre-fix gate source from `0e90a8d1c5` onto disk. On-disk proof: the `holdingTakesEffectIn` count is 0 and the `positionNameById` count is 2. The suite then ran red, `Tests 4 failed | 8 passed (12)`, and the 4 failures are exactly the witnesses above. I restored with `git checkout HEAD -- PATH` under an EXIT trap. The blob hash equals the `HEAD:` blob `44f891d329`, and `git diff HEAD` is 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 by `tsconfig.test.json`, confirmed with `--listFiles`. - ESLint, narrowed: `eslint --no-inline-config --format json` on the 2 changed `.ts` files reports 2 files, 0 errors, 0 warnings. Population: both files are matched by `eslint.config.mjs`'s `packages/**/*.{ts,...}` blocks, and neither is ignored (0 "file ignored" warnings). Invariance: the config enables no type-aware linting (no `parserOptions.project`, per its own header), so this diff cannot move the verdict for any untouched file. - `node scripts/pm/dispatch-gates.mjs --commands` derived 62 families. 59 exited 0. `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt` exited 3 with PREREQUISITE NOT MET (they need whole-workspace `dist/`), so they are **NOT MEASURED** and left to CI. - Roster families beside the path, all exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:tenant-chokepoint`, `check:error-code-casing`, `check:filter-alias-parity`. Changeset: `patch` for `@objectstack/plugin-security`. Zero edits under `packages/spec` or `content/docs/releases/**`. ## Acceptance notes These are observations only. None is filed and none is reproduced as a defect. - **The driver and the runtime disagree on an empty-string `organization_id`.** `SqlDriver.applyTenantScope` keeps 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. - **Binding against another organization's `sys_position` row id.** `positionById` reads 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](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19775
Clause-②: no
The delegated-administration gate resolved a scope's business-unit anchor by NAME across organizations. In a single-database multi-org posture (ADR-0105 D1
group/isolated; ADR-0132 「single-database organization isolation ships open」) a name collision therefore crossed an organization boundary, in both directions at once.The defect, verified at source before the repair
Verified on
origin/mainat the branch point2cf9db7c43, not taken from the card:delegated-admin-gate.ts:44—const SYSTEM_CTX = { isSystem: true } as const;carries no tenant.delegated-admin-gate.ts:912— the anchor lookup:ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX }). That is the by-name call. The descendant walk at:933-937runs under the same context with no organization predicate.packages/objectql/src/engine.ts:4295—hasTenantisexecCtx?.tenantId !== undefined && …, and:4316setsopts.tenantIdonly under it. It is independent ofisSystem, which is what makes the repair possible at all.packages/drivers/driver-sql/src/sql-driver.ts:13771—applyTenantScopereturns the builder untouched whentenantIdis undefined, null or empty.packages/platform-objects/src/identity/sys-business-unit.object.ts:260-263—namecarries no uniqueness; the only unique index is['code', 'organization_id'].So nothing scoped that read:
limit: 1plus the SQL driver'sORDER BY id ASCanswered "whichever id sorts first", across every organization.The repair — fail closed, and narrower at every choice
The anchor read, the descendant walk, and the two catalog reads behind
describeDelegableScopenow carry the caller's own organization, and the candidates that come back are reduced to the caller's own rows.organizationId ?? tenantId— the same spellingSecurityPlugin.callerOrganizationId(security-plugin.ts:989) already resolves a caller's permission sets with. TheadminScopethis gate reads arrives on a set loaded out of THAT organization's catalog, so anchoring its business unit anywhere else pairs an authority minted in one tenant with a tree owned by another.applyTenantScopecomposes a predicate, ANDresolveOwnOrganizationRow(reused fromper-organization-catalog.ts, no new symbol) picks the caller's own row out of what came back. The driver's compatibility arm deliberately also returns organization-less rows, and a driver with no tenant scoping at all returns every organization's.groupposture the anchor resolves in the caller's ACTIVE organization, not their whole membership set. Under a walled posture an organization-less business unit no longer answers a delegation boundary — that posture already declares such a row invalid state (per-organization-catalog.ts). A tenant admin's picker is unconstrained inside its organization and never across organizations.singleposture) keeps the by-name answer, pinned by its own test.describeDelegableScopeandscopesCoverUsertake the caller's context as a new OPTIONAL argument;DelegableScopeReportis byte-identical.ISecurityService.describeDelegableScope(callerContext)inpackages/specalready declared that parameter, so this lane touchespackages/specin zero lines.Evidence — the crossing itself, failing before and passing after
Two suites, deliberately complementary.
delegated-admin-gate-cross-organization.test.tsis a REALObjectQLover a REALSqlDriveron in-memory SQLite: two organizations each holding a unit namedsaleswith one child, ids ordered so the OTHER organization's sort first (bu_0_*beforebu_a_*).delegated-admin-gate.test.tsgains the companion block on a fakeqlthat ignorescontextentirely — a driver with no tenant scoping at all.BEFORE — the two source files reverted to the branch point
2cf9db7c43(proved on disk by blob hash,07ee0a9ff2…for the gate), the new test kept:Both measured directions of the defect are in those four lines: the org A delegate lost its own subtree (rejected where it should resolve), and the gate approved a delegated write anchored in org B (resolved where it should reject). The four that passed are the controls — ground truth, the single-organization dark control, the firing control, and the organization-less caller.
AFTER — same command, same tree, the repair restored (
git checkout HEAD -- …, restore proved bygit diff HEADempty and a matchinggit hash-object):ABLATION — each arm proved independently load-bearing. Removing only the row-level arm (
resolveOwnOrganizationRow(...).ownreplaced byrows[0] ?? null; the injected text confirmed present on disk and the deleted text confirmed absent, under anEXIT INT TERMtrap that restores fromHEAD):The unscoping-driver half goes red while the real-engine half stays green — exactly the split the code comment claims, so neither arm is decoration. The tree was restored byte-exact afterwards (
git hash-object=732cc64311b9a36af7a2e338e6fc86c8ff80f698, theHEADblob).No existing test asserted the defect. The package's 118 files / 2261 tests pass unchanged; nothing had to be corrected, and no admission decision was widened to make anything green.
Verification
pnpm --filter @objectstack/plugin-security testpnpm --filter @objectstack/plugin-security typecheckpnpm --filter '@objectstack/plugin-security^...' buildnode scripts/pm/dispatch-gates.mjs --commandsthen--ranpnpm lint(eslint . --no-inline-config)Three of the 62 first answered
PREREQUISITE NOT MET(exit 3, not a finding) because they read built output; all three are green afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'(72/72 successful):check:dual-build-cjs-loads,check:i18n,check:type-check-debt.check:where-matcherfound one real defect in my own new fixture — a$-prefixed key read as a field name — fixed inda621549c0and green. All readings are taken atda621549c0, the final commit.Not run here and left to CI: the five path-scheduled CI jobs, the type-check lanes over the whole workspace, and the artifact-roster families the derivation scores
silentfor every card.Acceptance notes
Observed while reading the fix face, deliberately NOT changed here — handed back to the PM rather than filed, and rather than widened into this PR:
sys_positionin the same file are the same defect class, unrepaired.positionIsDelegatable(:586pre-fix) andsetsBoundToPosition(:1005pre-fix) both doql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX }), andsys_positionis a per-organization catalog row upserted by(name, organization_id). They decide whether a position may be self-delegated and which permission sets it distributes, so a cross-organization row answering either one is an authority decision. The card listed them under "Not measured"; they need their own fixture and their own witness, which is a new verification surface this card's scope does not carry.businessUnitsOfUserandassignmentAnchorsOfPositionreadsys_business_unit_member/sys_user_positionunder the same bare system context. Both are keyed on a globally unique id rather than a name, so the collision this card is about does not reach them — noted as boundary, not claimed as a defect.Generated by Claude Code