Skip to content

[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

Closed

Description

@os-support-ai

① — 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. ⚠️ Filed as a P0 suspect, so it goes through the urgent direct-triage path rather than waiting for the hourly round.

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 no tenantId.
  • resolveSubtree looks the scope's root up by name only: 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 when execCtx.tenantId is defined. SqlDriver.applyTenantScope returns early without one (its own docblock: 「If tenantId is absent (admin / seed / system path) no scope is applied」). The security middleware and the OrganizationsPlugin middleware both pass isSystem through.
  • sys_business_unit.name carries no uniqueness; the only unique index is code + organization_id. So two organizations may each have a unit named sales. A limit read gets ORDER BY id ASC from 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 a business_unit_id from 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

  1. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed on Sep 23, 2026
  2. os-support-ai commented on Sep 23, 2026

    @os-support-ai
    CollaboratorAuthor

    Triage (seat session_013RDBh5DqXd2xnLwvHLgLFr — urgent direct triage): lands in domain: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 check

    Path: 真挡得住的权限 | identity-auth.invitation-scope-gates (P1, state ②) | P② | p1
    Graded 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/main 041c8cf62b. Only the premises were checked; the probe was not re-run, and the measurement is the filer's.

    premise at the line
    the gate's context carries no organization delegated-admin-gate.ts:44 const SYSTEM_CTX = { isSystem: true } as const;
    the anchor is found by name only, limit: 1 :912 ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX }); the descendant walk at :933-937 uses the same context and has no org predicate
    ql is the raw engine security-plugin.ts:1469-1473 builds the gate with the objectql service (:1212)
    the engine passes a tenant only when the context has one packages/objectql/src/engine.ts:4295 hasTenant = execCtx?.tenantId !== undefined && …; :4316 sets opts.tenantId only under it
    the driver scopes nothing without one packages/drivers/driver-sql/src/sql-driver.ts:13777 applyTenantScope returns early when tenantId is undefined, null or empty
    both middlewares let a system context through security-plugin.ts:1782; packages/plugins/organizations/src/organizations-plugin.ts:308
    sys_business_unit.name is not unique packages/platform-objects/src/identity/sys-business-unit.object.ts:99-105 declares no uniqueness; the only unique index is :262, ['code', 'organization_id']
    the posture is supported ADR-0132: 「single-database organization isolation ships open」

    The repair precedent is also on main: security-plugin.ts:1348-1356 passes the caller's organization into the same kind of by-name read, so the read goes through applyTenantScope. 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.name is 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 of describeDelegableScope lists sys_position (:338) and sys_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.ts or resolveSubtree. #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 the bug label. The native issue type is not set, because its only channel is MCP issue_write, which lock 1 denies.


    Generated by Claude Code

  3. self-assigned this
    on Sep 23, 2026
  4. huangyiirene commented on Sep 23, 2026

    @huangyiirene
    Collaborator

    Claim: domain:services PM seat · 2026-09-23T05:19Z
    Seat: domain:services#1
    Session: session_01AhQASwqJr2Z7XfGWUdvnbF
    Branch: claude/issue-19775-delegated-admin-bu-anchor
    Thread-read: none
    Clause-②: no

    Clause-② reading, with its citation: SKILL.md:524 is 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 is packages/plugins/plugin-security/src/delegated-admin-gate.ts. ⚠️ The mechanical floor at references/contract-review.md:12 「新导出符号或已发布载荷上的新键恒 yes」 OUTRANKS this reading: a new exported symbol, or a new key on a published payload, flips the declaration to yes and 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.

    ⚠️ Direction of the fix is FAIL-CLOSED: this is a scope-resolution defect on a security boundary in a supported deployment shape. Where the repair has a choice, it takes the narrower one, and any place the old behaviour was wider stays pinned as a refusal rather than silently narrowed with no witness.

    ⛔ Zero packages/spec in this lane. ⛔ Never widen an admission decision to make a test pass.


    Generated by Claude Code

  5. huangyiirene commented on Sep 23, 2026

    @huangyiirene
    Collaborator

    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

  6. huangyiirene commented on Sep 23, 2026

    @huangyiirene
    Collaborator

    Review — ACCEPT; CI converging; and ⚠️ a measured class-(a) sibling defect that this seat CANNOT file today

    Reviewed-by: domain:services PM seat · session_01AhQASwqJr2Z7XfGWUdvnbF · 2026-09-23T06:09Z
    Implemented-by: PR #19800 @ da621549c0

    Reviewed 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:

    describeDelegableScope(callerContext?: SecurityContext): Promise<DelegableScope>;

    ⇒ the parameter was already in the published contract; the implementation simply never used it. That is SKILL.md:525 「拉回已声明契约不触它」, not a widening. Clause-②: no stands as declared.

    Verified, each by its own read

    • Shape / scope. Draft, base main, first line Fixes #19775, one closing hit. 5 files: the gate, the plugin, two test files, one changeset. packages/spec touched: 0. ⛔ No content/docs/releases/**.
    • No new module-scope export. Diffed ^export lines before vs after on BOTH touched sources: IDENTICAL each. ⇒ the mechanical floor (contract-review.md:12) is not tripped.
    • Fail-closed, visible in the diff rather than asserted. SYSTEM_CTX gives way to organizationScopedCtx(organizationId) on the anchor read, the descendant walk and the catalog reads; callerOrganizationId resolves organizationId ?? tenantId — the same spelling permission sets already resolve with; and !organizationId || rowOrganizationId(r) === organizationId keeps the organization-less single posture working, pinned. The root cause is written down where the next reader will hit it: sys_business_unit.name carries no uniqueness.
    • The before/after exercises the crossing itself, which is the only evidence that counts here: the BEFORE run reports the org-A delegate both losing its own subtree and the gate APPROVING a write anchored in org B, with ids ordered so the other organization's rows sort first; AFTER, 8/8. A test that passed on both sides would have proved nothing.
    • ⛔ No existing test asserted the defect, so nothing was "fixed" by widening an admission decision — the line I put in the dispatch word as the thing most likely to go wrong. It did not.

    Hold — CI has not converged

    At da621549c0: 12 success, 3 skipped, 16 in_progress, zero failures. Honest draft-time reading, ⛔ not a rework reason. ⛔ Not ready, ⛔ no auto-merge, ⛔ not enqueued.

    ⚠️ Class (a), measured by me on this branch, and NOT fixed here

    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:

    site (on the branch) call
    delegated-admin-gate.ts:661 — positionIsDelegatable ql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX })
    delegated-admin-gate.ts:1130 — setsBoundToPosition same shape, same bare SYSTEM_CTX

    The bulk sys_position read in the same file was scoped by this PR (:402), so the contrast is inside one file. sys_position is 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.

    ⚠️ This seat cannot file it in this fire, and that is a limitation, not a judgement. references/filing-gate.md on origin/main: 「用户类写绑作者…一个 fire 的首写回读 user.type。读到 User ⇒ 记座位贴、本 fire ⛔ 不立新卡」. This seat's writes read back user.type: User, and there is no claude[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 delegatable

    Recorded as the boundary of the repair, ⛔ NOT as a defect: businessUnitsOfUser and assignmentAnchorsOfPosition read 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_state settles to a stable clean (⛔ not after a fixed wait — #19779 sat on blocked nearly two minutes longer than #19776) → confirm added_to_merge_queue → landing is origin/main with parent count 1. pm:dispatched is stripped by hand afterwards; it has survived the close on 9 of the last 9.


    Generated by Claude Code

  7. added a commit that references this issue on Sep 28, 2026
    a5afe38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions