Skip to content

[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

@objectstack-fleet

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

  1. Fresh boot: OS_PORT=<p> pnpm -C examples/app-showcase exec objectstack dev --seed-admin -p <p> -d file:<fresh>.db; admin admin@objectos.ai / admin123.
  2. Admin invites + signs up a delegate D and a target T (POST /api/v1/auth/organization/invite-member, then POST /api/v1/auth/sign-up/email).
  3. 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.
  4. D: POST /api/v1/data/sys_user_position {"user_id":T,"position":"contributor","business_unit_id":"bu_west_coast"} (bu_west_coast is a child of bu_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.
  5. Discriminating experiment: admin PATCHes organization_id onto the seeded bu_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_unit rows have organization_id: null by design — the seed loader never stamps the fallback organization on sys_* seeds (packages/metadata-protocol/src/seed-loader.ts).
  • delegated-admin-gate.ts callerOrganizationId() returns context.organizationId ?? context.tenantId without consulting the tenancy posture. Its docblock says the value is "Undefined for a single-posture caller", but the dev boot's default-organization bootstrap gives every session an activeOrganizationId, so it is always defined (boot banner: Tenancy: single).
  • resolveSubtree then reads the scope root through resolveOwnOrganizationRow(...).own, which only matches rows stamped with that organization (per-organization-catalog.ts); resolveSubtreeById also drops children whose organization_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.ts calls org-less rows "the CORRECT shape" under the single posture, and the enforcement loader dbLoaderFor in security-plugin.ts uses own ?? organizationLessResidue, with a comment that resolveOwnOrganizationRow is written for seeders. DelegatedAdminGate is 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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions