Skip to content

auto-org-admin-grant resolves the organization_admin set id by name alone (limit 1, unscoped, process-cached), so walled org-admin grants can point at the organization-less row #11670

Description

@claude

Measured while designing #11663 (platform-admin re-anchor). Filed unassigned; no fix
attempted
— scoping a grant resolver is not that design card's surface, and the card's own
reap leg depends on this being settled first.

What the code says

packages/plugins/plugin-security/src/auto-org-admin-grant.ts:212-229 resolves the
organization_admin permission-set row id that every auto-provisioned org-admin grant points at:

const rows = await tryFind(ql, 'sys_permission_set', { name }, 1, logger);
const id = rows[0]?.id;

Three properties, all deliberate-looking, that combine badly under a walled posture:

  1. name-only — no organization_id predicate, and the read is not threaded through
    resolveOwnOrganizationRow, which per-organization-catalog.ts documents as "the one read
    that distinguishes 'this organization has its row' from 'somebody's organization-less row is
    visible here'"
    and which every seeder in that catalog routes through;
  2. limit: 1 — so whichever row the driver returns first is the answer, with no way to
    notice a second one existed;
  3. cached per ObjectQL instance (permissionSetIdCache WeakMap) — so the first answer of the
    process is the answer for every organization for the rest of the process.

Why the combination matters

Post-#10103 the catalog is materialized per organization: sys_permission_set.name is unique
per organization (ADR-0120 D3, COALESCE(organization_id, '__global__')), so one name legitimately
has a row per organization plus the organization-less platform-bucket row.

#11532 measured, on a fresh walled rig, that the organization-less rows are written 1.3 s before
the first sys_organization exists
— i.e. the organization-less organization_admin row is the
oldest row bearing that name. A name-only limit: 1 read under a system context has no reason
to prefer any other one.

⇒ On a walled deployment, sys_user_permission_set rows granting organization_admin can point at
the organization-less row by id, rather than at the granting organization's own copy. Reads
still resolve (resolve-authz-context.ts:570 resolves permission sets by id without tenant
scoping, unlike the sys_position read one block above at :538), which is exactly why this is
invisible today.

Two consequences

  • A reap of the organization-less platform bucket is not safe on the arithmetic the summaries
    use.
    "Only admin_full_access is pointed at by row id; the other seven have per-organization
    copies" does not follow — having a per-organization copy does not mean the grants point at it.
    per-organization-catalog.ts already warns in its own header that these rows are grant TARGETS
    and that deleting them "revokes standing access with no signal at the moment of loss"; this
    finding says the target set is wider than admin_full_access.
  • The organization answer is decided by process-startup ordering, not by the granting
    organization — a per-instance cache over an unscoped read means the first organization
    reconciled in a process picks the row every later organization gets.

Not claimed

  • Not measured on a live rig. This is a code-shape reading: name-only + limit: 1 +
    unscoped + cached. Which row a real driver returns first on a real walled deployment was not
    observed. That observation is the first thing a fix should take, and it decides the severity.
  • No user-visible defect is claimed. Under the current reads the grant still resolves, so the
    symptom today is a mis-targeted foreign key, not a denied request.

Suggested shape

Thread organizationId into resolvePermissionSetId and route it through
resolveOwnOrganizationRow (the governed spelling), keying the cache on
(name, organizationId) rather than name. Under single posture the unscoped answer stays
correct and unchanged — that carve-out is already how the catalog handles this split.

Refs

Related: #11663 (its reap leg is gated on this) · #11532 (the 1.3 s ordering and the 8 rows) ·
#10103 (the per-organization ruling) · ADR-0120 D3. #11663 remains open and is not addressed here.


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions