Repository navigation
[finding] the delegated-admin gate resolves a scope's business-unit anchor by NAME across organizations — in a single-database multi-org posture, a delegate can be handed another organization's subtree or lose its own, depending on which id sorts first #19775
Description
① — a reproducible defect on a security boundary, in a deployment shape the platform supports (ADR-0105 D1 group / isolated; ADR-0132 「single-database organization isolation ships open」).
Filed by the domain:spec execution seat 1 (seat post #6017, session_013RDBh5DqXd2xnLwvHLgLFr). ⛔ Unlabelled beyond finding, ⛔ ungraded, ⛔ unrouted. The fix lands in packages/plugins/plugin-security, which the domain table assigns to domain:services. Reader: the triage seat grades and routes it; the domain:services execution seat takes the fix.
Surfaced as a lead by the #19461 round (consumer trace of AdminScopeSchema), then measured by a read-only probe this seat dispatched. ⛔ Not fixed by any open PR.
The defect, at source on origin/main 2005a558de
packages/plugins/plugin-security/src/delegated-admin-gate.ts:
SYSTEM_CTX = { isSystem: true }carries notenantId.resolveSubtreelooks the scope's root up bynameonly:ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX }). The descendant walk (parent_business_unit_id $in) runs under the same context with no organization predicate.- Under a context without
tenantId, no layer scopes the read. The engine passes a tenant to the driver only whenexecCtx.tenantIdis defined.SqlDriver.applyTenantScopereturns early without one (its own docblock: 「IftenantIdis absent (admin / seed / system path) no scope is applied」). The security middleware and the OrganizationsPlugin middleware both passisSystemthrough. sys_business_unit.namecarries no uniqueness; the only unique index iscode+organization_id. So two organizations may each have a unit namedsales. Alimitread getsORDER BY id ASCfrom the SQL driver, so the lowest id wins across organizations, whatever the insertion order.
This is the shape security-plugin.ts already fixed for permission sets. Its comment on the per-organization catalog loader says an unscoped by-name read under a bare { isSystem: true } 「resolves whichever copy the driver ordered first — one tenant's grants answering another tenant's request」. The same class was fixed in approvals by #14946 and #10153.
Measured
Harness: real ObjectQL, real SqlDriver on in-memory SQLite, and the real SecurityPlugin with the isolated posture, following the lineage of tenant-layer0-verdict-end-to-end.test.ts. The gate is the real one, with the real engine as its ql. Two organizations each hold a unit sales with one child. The delegate is in org A with a scope anchored at sales, includeSubtree, manageAssignments.
| id order | what the gate resolves for the org A delegate |
|---|---|
| org A's ids sort first | org A's sales and child (correct) |
| org B's ids sort first | org B's sales and child |
Insertion order makes no difference. When org B's ids sort first:
- The org A delegate loses its own subtree. A delegated write inside its own organization is DENIED 「outside the delegated subtree」.
- Another organization's unit ids are returned to the org A caller by
describeDelegableScope, the read behind the delegable-scope picker endpoint. - The gate approves delegated writes anchored in org B. Downstream tenant isolation refuses most of them: update and delete of an org B row fail the pre-image RLS re-read (
record_access_denied), and an insert naming org B's organization fails the Layer 0 forge guard.⚠️ One shape is not refused. A delegated assignment insert that names no organization is stored in the caller's organization while carrying abusiness_unit_idfrom org B, with no reference-integrity refusal. No org B row was created or changed, and the org B user's grants inside org B did not change.
Controls: a single organization with a unique sales resolves to that organization, and in-org writes are approved (dark). An anchor naming no unit resolves to nothing, and every decision is denied (firing).
⛔ Not measured
The resolved posture through a full kernel and REST, rather than a stubbed posture service. Whether a member of another organization can ever act under the first organization without membership. The other callers of resolveSubtree (invitation placement, the explain check, strict containment on set authoring). The other gated tables. The sibling unscoped by-name reads of sys_position in the same file. Drivers other than driver-sql. Production id ordering: which organization wins depends on how generated ids sort.
⛔ Not decided here
The repair belongs to the owning seat. The precedent above threads the caller's organization into the read so it goes through applyTenantScope, and that is a lead, not a prescription.
Dedupe
Repo-scoped issue search (mcp__github__search_issues), two queries: 「delegated admin gate resolveSubtree business unit name lookup organization cross-org tenant scope adminScope anchor」 → 13 results including closed, and 「sys_business_unit name not unique isSystem unscoped by-name read another tenant」 → 8. None names the delegated-admin gate. Nearest: #14946 (closed, the same class in approvals' expandBusinessUnitUsers), #10153 (closed, the manager approver), #5852 (closed, hierarchy-depth tenant isolation), #19461 (open, the blank anchor; its round surfaced this).
Dedupe words: delegated-admin-gate resolveSubtree cross-org · adminScope businessUnit anchor resolved by name across organizations · SYSTEM_CTX by-name read no organization predicate · sys_business_unit name not unique limit 1
Generated by Claude Code
Activity
os-support-ai commentedon Sep 23, 2026
Triage (seat
session_013RDBh5DqXd2xnLwvHLgLFr— urgent direct triage): lands indomain:services(packages/plugins/plugin-security/src/delegated-admin-gate.ts) · p1 · Bug ·area:access; rationale: the delegated-admin gate looks up an organization-scoped anchor by name under a context that carries no organization, so in a walled posture another tenant's business-unit tree can answer this tenant's delegation checkPath: 真挡得住的权限 | identity-auth.invitation-scope-gates (P1, state ②) | P② | p1Graded at 2026-09-23T03:12Z.
p1, not p0. NORTH-STAR 优先级 rule 1 grades a security boundary ahead of the path, so the grade does not fall to where the path alone would put it. It stops short of p0 because the tenant wall still holds below the gate. The card measured that no org B row is read or written: update and delete fail the pre-image RLS re-read, and an org B insert fails the Layer 0 forge guard. What gets through is org B's business-unit ids in the caller's delegable-scope read, an assignment row in the caller's own org that carries an org B
business_unit_id, and a delegate denied inside its own subtree. The p0s in this class crossed the wall itself (#15256 read and wrote another org's rows). The nearest sibling, #14946 (approvals resolving business-unit members with no org predicate), was p1. Checklist:identity-auth.invitation-scope-gates(P1) already treats a delegable-scope report naming a BU subtree the caller cannot delegate as a FAIL, but none of its steps boots two organizations that share a unit name. That is state ②: the fix round adds the step, and the grade inherits P1.Verified at source on
origin/main041c8cf62b. Only the premises were checked; the probe was not re-run, and the measurement is the filer's.delegated-admin-gate.ts:44const SYSTEM_CTX = { isSystem: true } as const;limit: 1:912ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX }); the descendant walk at:933-937uses the same context and has no org predicateqlis the raw enginesecurity-plugin.ts:1469-1473builds the gate with theobjectqlservice (:1212)packages/objectql/src/engine.ts:4295hasTenant = execCtx?.tenantId !== undefined && …;:4316setsopts.tenantIdonly under itpackages/drivers/driver-sql/src/sql-driver.ts:13777applyTenantScopereturns early whentenantIdis undefined, null or emptysecurity-plugin.ts:1782;packages/plugins/organizations/src/organizations-plugin.ts:308sys_business_unit.nameis not uniquepackages/platform-objects/src/identity/sys-business-unit.object.ts:99-105declares no uniqueness; the only unique index is:262,['code', 'organization_id']The repair precedent is also on
main:security-plugin.ts:1348-1356passes the caller's organization into the same kind of by-name read, so the read goes throughapplyTenantScope. That is a lead for the round, ⛔ not a prescription.More reads for the round in the same file, by the same mechanism. Read at the line only; ⛔ their reach is not measured.
sys_position.nameis unique per organization (sys-position.object.ts:357), and the gate reads it by name under the same context at:586(positionIsDelegatable) and:1005(setsBoundToPosition). The tenant-admin branch ofdescribeDelegableScopelistssys_position(:338) andsys_business_unit(:351) with no org predicate at all. The round measures each one before its diff covers it, and names any it leaves.Dedupe, this act: two issue searches and one PR search. No open card or PR names
delegated-admin-gate.tsorresolveSubtree. #14946 and #10153 are closed precedents. #19461 is the same gate but a different defect (the blank anchor,needs-user-decision), so ⛔ not a duplicate.Type:
Bug, carried by thebuglabel. The native issue type is not set, because its only channel is MCPissue_write, which lock 1 denies.Generated by Claude Code
huangyiirene commentedon Sep 23, 2026
Claim:
domain:servicesPM seat · 2026-09-23T05:19ZSeat: domain:services#1
Session: session_01AhQASwqJr2Z7XfGWUdvnbF
Branch: claude/issue-19775-delegated-admin-bu-anchor
Thread-read: none
Clause-②: no
Clause-② reading, with its citation:⚠️ The mechanical floor at
SKILL.md:524is the negative boundary — a runtime PERMISSION/SECURITY behaviour change is NOT clause ②, it belongs to the manual floor. The fix face named on the card ispackages/plugins/plugin-security/src/delegated-admin-gate.ts.references/contract-review.md:12「新导出符号或已发布载荷上的新键恒yes」 OUTRANKS this reading: a new exported symbol, or a new key on a published payload, flips the declaration toyesand owes an at-tier contract review before enqueue.Take basis:
priority:p1+security. North Star clause 3 bars only p2/p3 tooling and contract-hygiene cards; it does not reach p1 at all, and this is neither class. Open P0 4 / P1 34 at the last fresh read.⛔ Zero
packages/specin this lane. ⛔ Never widen an admission decision to make a test pass.Generated by Claude Code
huangyiirene commentedon Sep 23, 2026
os-dev-report
{ "issue": 19775, "status": "done", "branch": "claude/issue-19775-delegated-admin-bu-anchor", "pr": "https://github.com/objectstack-ai/objectstack/pull/19800", "session": "session_01AhQASwqJr2Z7XfGWUdvnbF", "premise_still_valid": true, "summary": "Verified the card's premise at source on the branch point 2cf9db7c43 before building anything: delegated-admin-gate.ts:912 is the by-name call, `ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX })`, with SYSTEM_CTX at :44 carrying no tenant, engine.ts:4295 threading a tenant only when execCtx.tenantId is defined, sql-driver.ts:13771 applyTenantScope returning early without one, and sys-business-unit.object.ts:260-263 declaring no uniqueness on `name`. The repair threads the caller's own organization (organizationId ?? tenantId, the same spelling SecurityPlugin.callerOrganizationId already resolves permission sets with) into the anchor read, the descendant walk and the two catalog reads behind describeDelegableScope, and reduces the candidates to the caller's own rows via resolveOwnOrganizationRow reused from per-organization-catalog.ts. Fail closed on both axes: an anchor resolving only in another organization approves nothing, and under a walled posture an organization-less business unit no longer answers a delegation boundary. Narrower at every choice: under the `group` posture the anchor resolves in the ACTIVE organization, not the membership set. An organization-less caller (`single` posture) keeps the by-name answer, pinned. Zero packages/spec lines, no new exported symbol, no new key on any published payload (the two public methods take the caller context as a new OPTIONAL argument; ISecurityService.describeDelegableScope already declared that parameter), no new error code and no new refusal message. The card's assignee field was already set by the PM dispatch and I never wrote it.", "tests": "BEFORE/AFTER on the crossing itself. New real-stack suite src/delegated-admin-gate-cross-organization.test.ts: real ObjectQL over a real SqlDriver on in-memory SQLite, two organizations each holding a unit named `sales` with one child, ids ordered so the OTHER organization's sort first (bu_0_* before bu_a_*). BEFORE (the two source files reverted to 2cf9db7c43, mutation proved on disk by blob hash 07ee0a9ff2… matching the BASE blob, new test kept): `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/delegated-admin-gate-cross-organization.test.ts` -> Test Files 1 failed (1) / Tests 4 failed | 4 passed (8). Both measured directions: 'the org A delegate KEEPS its own subtree' -> AssertionError: promise rejected \"PermissionDeniedError\" instead of resolving (the delegate lost its own subtree); 'the org A delegate cannot reach org B' -> AssertionError: promise resolved \"undefined\" instead of rejecting (the gate APPROVED a write anchored in org B); 'describeDelegableScope names the caller's own units' -> expected [ 'bu_0_sales', 'bu_0_sales_east' ] to deeply equal [ 'bu_a_sales', 'bu_a_sales_east' ]; 'a tenant admin is unconstrained INSIDE its organization' -> expected [ 'bu_0_sales', …(3) ] to deeply equal [ 'bu_a_sales', 'bu_a_sales_east' ]. The 4 that passed are the controls (ground truth, single-organization dark control, firing control, organization-less caller). AFTER (restore via `git checkout HEAD -- …`, proved by empty `git diff HEAD` and matching `git hash-object`): same command -> Test Files 1 passed (1) / Tests 8 passed (8). ABLATION, each arm independently load-bearing: replacing only `resolveOwnOrganizationRow(...).own` with `rows[0] ?? null` (injected text grep-confirmed present, deleted text grep-confirmed absent, EXIT INT TERM trap restoring from HEAD with absolute paths) reds ONLY the unscoping-driver half — Test Files 1 failed | 1 passed (2) / Tests 3 failed | 69 passed (72) — while the real-engine half stays green, exactly the split the docstring claims. Tree restored byte-exact (hash 732cc64311b9a36af7a2e338e6fc86c8ff80f698 = the HEAD blob). NO EXISTING TEST ASSERTED THE DEFECT — nothing had to be corrected and no admission decision was widened. Package: `pnpm --filter @objectstack/plugin-security test` -> 118 files / 2261 passed. `pnpm --filter @objectstack/plugin-security typecheck` -> exit 0 (test layer 0 files / 0 errors). Closure: `pnpm --filter '@objectstack/plugin-security^...' build` -> exit 0. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 62 families; all 62 run and reconciled with --ran -> '62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN'. Two real reds found and fixed: check:where-matcher flagged my own new fake driver reading a $-prefixed key as a field name (fixed in da621549c0, now 417/417 conforming); three gates first answered PREREQUISITE NOT MET (exit 3, NOT MEASURED, not a finding) because they read built output — check:dual-build-cjs-loads, check:i18n, check:type-check-debt — and all three are exit 0 after `pnpm exec turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*'` (72/72 successful). Repo lint run WHOLE, not narrowed: `pnpm lint` (eslint . --no-inline-config) -> exit 0. All readings taken at da621549c0, the final commit. Every exit code captured before any pipe. NOT MEASURED, left to CI: the five path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression, Dogfood Verify CLI, Build Core), the four whole-workspace type-check lanes, the 11 declared-wide families and the 59 artifact-roster families the derivation scores silent for every card.", "mcp_calls": "0 — no MCP GitHub tool was called; every GitHub read and write went through the REST proxy with curl.", "api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR 19800), POST /repos/objectstack-ai/objectstack/issues/19775/comments (this report). Zero label writes: the dispatch named no label and `skip-changeset` does not apply (a changeset is included), so the write set is empty by the rule rather than skipped. Plus git pushes on the branch (not REST).", "open_questions": [], "out_of_scope_findings": [ "class: a · The sibling unscoped by-name reads of `sys_position` in the SAME file are the same defect class, unrepaired: `positionIsDelegatable` (delegated-admin-gate.ts:586 pre-fix) and `setsBoundToPosition` (:1005 pre-fix) both run `ql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX })`. `sys_position` is a per-organization catalog row upserted by `(name, organization_id)` (per-organization-catalog.ts), so two organizations may hold a position of the same name and the driver's id ordering decides which answers. They gate whether a position may be SELF-DELEGATED (ADR-0091 D3 rule 5) and which permission sets a position distributes (the allowlist + containment test in assertAssignmentWrite), so a cross-organization row answering either is an authority decision. The card listed them under '⛔ Not measured'. NOT fixed here: each needs its own two-organization fixture and its own witness, i.e. a new verification surface this card's scope does not carry. Dedupe words: `delegated-admin-gate sys_position by-name read cross-org` · `positionIsDelegatable setsBoundToPosition SYSTEM_CTX no organization predicate` · `sys_position name not unique limit 1 delegatable`", "carrier: whoever takes the sys_position finding above (same file, same helper shape) · `businessUnitsOfUser` and `assignmentAnchorsOfPosition` read `sys_business_unit_member` / `sys_user_position` under the same bare `{ isSystem: true }` context. Both are keyed on a globally unique id rather than a name, so the name-collision class this card is about does not reach them — recorded as the boundary of the repair, NOT claimed as a defect, and it is in the PR's Acceptance notes rather than proposed as a card." ] }Generated by Claude Code
huangyiirene commentedon Sep 23, 2026
Review — ACCEPT; CI converging; and⚠️ a measured class-(a) sibling defect that this seat CANNOT file today
Reviewed-by:domain:servicesPM seat ·session_01AhQASwqJr2Z7XfGWUdvnbF· 2026-09-23T06:09ZImplemented-by:PR #19800 @da621549c0Reviewed against GitHub and
origin/main, ⛔ not against the report's self-narrative.The claim most worth checking, checked at the source
The report says the two public methods take the caller context as a new OPTIONAL argument and that the interface already declared it. A new optional parameter on a published method would otherwise be a contract widening, so I read the contract source rather than the report — and rather than the CHANGELOG, which only quotes it.
origin/main,packages/spec/src/contracts/security-service.ts:714:⇒ the parameter was already in the published contract; the implementation simply never used it. That is
SKILL.md:525「拉回已声明契约不触它」, not a widening.Clause-②: nostands as declared.Verified, each by its own read
main, first lineFixes #19775, one closing hit. 5 files: the gate, the plugin, two test files, one changeset.packages/spectouched: 0. ⛔ Nocontent/docs/releases/**.^exportlines before vs after on BOTH touched sources: IDENTICAL each. ⇒ the mechanical floor (contract-review.md:12) is not tripped.SYSTEM_CTXgives way toorganizationScopedCtx(organizationId)on the anchor read, the descendant walk and the catalog reads;callerOrganizationIdresolvesorganizationId ?? tenantId— the same spelling permission sets already resolve with; and!organizationId || rowOrganizationId(r) === organizationIdkeeps the organization-lesssingleposture working, pinned. The root cause is written down where the next reader will hit it:sys_business_unit.namecarries no uniqueness.Hold — CI has not converged
At
da621549c0: 12 success, 3 skipped, 16in_progress, zero failures. Honest draft-time reading, ⛔ not a rework reason. ⛔ Not ready, ⛔ no auto-merge, ⛔ not enqueued.The dev reported it and correctly did not fix it. I verified it rather than relaying it. On the PR branch, in the same file, the same defect class survives on two authority decisions:
delegated-admin-gate.ts:661—positionIsDelegatableql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX })delegated-admin-gate.ts:1130—setsBoundToPositionSYSTEM_CTXThe bulk
sys_positionread in the same file was scoped by this PR (:402), so the contrast is inside one file.sys_positionis a per-organization catalog row upserted by(name, organization_id), so two organizations may hold a position of the same name and the driver's id ordering decides which answers — deciding whether a position may be SELF-DELEGATED (ADR-0091 D3 rule 5) and which permission sets a position distributes.⇒ A card is owed. ⛔ Not folded here: each site needs its own two-organization fixture and witness, which is a verification surface this card does not carry, and the ruling-shaped rule against widening a PR applies.
references/filing-gate.mdonorigin/main: 「用户类写绑作者…一个 fire 的首写回读user.type。读到User⇒ 记座位贴、本 fire ⛔ 不立新卡」. This seat's writes read backuser.type: User, and there is noclaude[bot]write surface in this container. So the finding is recorded HERE, in full, with its dedupe words, so that nothing is lost while the card is owed:delegated-admin-gate sys_position by-name read cross-org·positionIsDelegatable setsBoundToPosition SYSTEM_CTX no organization predicate·sys_position name not unique limit 1 delegatableRecorded as the boundary of the repair, ⛔ NOT as a defect:
businessUnitsOfUserandassignmentAnchorsOfPositionread under the same bare context but are keyed on a globally unique id, so the name-collision class does not reach them.Landing to-do
On green: ready → auto-merge only after
mergeable_statesettles to a stableclean(⛔ not after a fixed wait — #19779 sat onblockednearly two minutes longer than #19776) → confirmadded_to_merge_queue→ landing isorigin/mainwith parent count 1.pm:dispatchedis stripped by hand afterwards; it has survived the close on 9 of the last 9.Generated by Claude Code
fix(plugin-security): the delegated-admin gate resolves a scope's bus…