Repository navigation
[finding] The delegated-admin gate resolves an EMPTY subtree on a stock objectstack dev boot — seeded business units are organization-less while every session carries an active organization, so every in-scope delegated write is refused #21057
Description
Blocked-by: #15193
Filing gate: ① a reproducible product defect with a named landing site — packages/plugins/plugin-security/src/delegated-admin-gate.ts. reach: the public REST data API, reproduced on four fresh objectstack dev --seed-admin showcase boots (runner + independent verifier). Reader: triage first touch, then the lane owning plugin-security. Dedupe: semantic search "delegated admin subtree business unit organization_id null outside the delegated subtree" (open + closed) → 8 hits, none this defect; nearest are the same gate's cross-organization repairs #19775 / #19819 / #19860 (closed) and the ADR-0131 epic #15194 (open, no NULL organization_id).
QA-source: #21056 · access-security.crud-permission-matrix · acceptance[0]
What happens
ADR-0090 D12 delegated administration does not work on a stock dev boot. A delegate holding showcase_field_ops_delegate (adminScope Field Operations + subtree, manageAssignments, assignablePermissionSets: [showcase_contributor, showcase_manager]) is refused every in-subtree assignment. The gate fails closed, so this is over-refusal, not exposure.
Reproduction
- Fresh boot:
OS_PORT=<p> pnpm -C examples/app-showcase exec objectstack dev --seed-admin -p <p> -d file:<fresh>.db; adminadmin@objectos.ai/admin123. - Admin invites + signs up a delegate D and a target T (
POST /api/v1/auth/organization/invite-member, thenPOST /api/v1/auth/sign-up/email). - Admin:
POST /api/v1/data/sys_user_permission_set {"user_id":D,"permission_set_id":<showcase_field_ops_delegate>}→ 201;POST /api/v1/data/sys_business_unit_member {"business_unit_id":"bu_field_ops","user_id":D}→ 201. - D:
POST /api/v1/data/sys_user_position {"user_id":T,"position":"contributor","business_unit_id":"bu_west_coast"}(bu_west_coastis a child ofbu_field_ops).- Expected: 2xx.
- Actual:
403 PERMISSION_DENIED "delegated 'insert' on sys_user_position rejected — business unit 'bu_west_coast' is outside the delegated subtree (scope from 'showcase_field_ops_delegate')". - Control: admin posting the identical payload → 201.
- Discriminating experiment: admin
PATCHesorganization_idonto the seededbu_field_ops/bu_west_coast/bu_east_coast→ D's same call now → 201. An org-stamped "Field Operations" tree created through REST also works.
Mechanism
- The seeded
sys_business_unitrows haveorganization_id: nullby design — the seed loader never stamps the fallback organization onsys_*seeds (packages/metadata-protocol/src/seed-loader.ts). delegated-admin-gate.tscallerOrganizationId()returnscontext.organizationId ?? context.tenantIdwithout consulting the tenancy posture. Its docblock says the value is "Undefined for asingle-posture caller", but the dev boot's default-organization bootstrap gives every session anactiveOrganizationId, so it is always defined (boot banner:Tenancy: single).resolveSubtreethen reads the scope root throughresolveOwnOrganizationRow(...).own, which only matches rows stamped with that organization (per-organization-catalog.ts);resolveSubtreeByIdalso drops children whoseorganization_id !== organizationId. The org-less seeded tree is filtered out, the subtree is empty, and every write fails closed.- The rest of the plugin reads the opposite way:
per-organization-catalog.tscalls org-less rows "the CORRECT shape" under thesingleposture, and the enforcement loaderdbLoaderForinsecurity-plugin.tsusesown ?? organizationLessResidue, with a comment thatresolveOwnOrganizationRowis written for seeders.DelegatedAdminGateis constructed with{ ql, resolveSets, logger }only and receives no posture.
Why the pin is green
packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (13/13) boots the shared stack without orgContext, so packages/verify/src/harness.ts sets autoDefaultOrganization: false; the context carries no organization and the gate takes its by-name branch. The pin never models the real dev boot.
Landing site and acceptance
delegated-admin-gate.ts(callerOrganizationId/resolveSubtree/resolveSubtreeById): resolve the scope the waydbLoaderFordoes — accept the org-less residue under a non-walled posture (or pass the posture in), without reopening the cross-organization by-name hazard [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.- Pin: run the delegate-assignment cases in an isolated project with
orgContext: true(the shared stack cannot carry it), asserting an in-subtree assignment succeeds and an out-of-subtree one is refused. - Done when step 4 answers 2xx on a stock dev boot and
access-security.crud-permission-matrixacceptance[0] re-runs green.
Related symptom, same class, not verified here: every stock boot warns [sharing-rule] share_new_inquiries_with_field_ops expands to NO recipients … not organization-stamped.
Triage note (not a verdict): a delegate's PATCH/DELETE on an assignment ANOTHER user created is also refused by the platform ownership floor (record_access_denied) even after the units are stamped; the floor does not yield to adminScope.manageAssignments, so a fixed gate may still not let a delegate re-staff others' assignments.
Generated by Claude Code
Activity