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:
- 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; limit: 1 — so whichever row the driver returns first is the answer, with no way to
notice a second one existed;- 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
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-229resolves theorganization_adminpermission-set row id that every auto-provisioned org-admin grant points at:Three properties, all deliberate-looking, that combine badly under a walled posture:
organization_idpredicate, and the read is not threaded throughresolveOwnOrganizationRow, whichper-organization-catalog.tsdocuments as "the one readthat distinguishes 'this organization has its row' from 'somebody's organization-less row is
visible here'" and which every seeder in that catalog routes through;
limit: 1— so whichever row the driver returns first is the answer, with no way tonotice a second one existed;
permissionSetIdCacheWeakMap) — so the first answer of theprocess 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.nameis uniqueper organization (ADR-0120 D3,
COALESCE(organization_id, '__global__')), so one name legitimatelyhas 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_organizationexists — i.e. the organization-lessorganization_adminrow is theoldest row bearing that name. A name-only
limit: 1read under a system context has no reasonto prefer any other one.
⇒ On a walled deployment,
sys_user_permission_setrows grantingorganization_admincan point atthe organization-less row by id, rather than at the granting organization's own copy. Reads
still resolve (
resolve-authz-context.ts:570resolves permission sets by id without tenantscoping, unlike the
sys_positionread one block above at:538), which is exactly why this isinvisible today.
Two consequences
use. "Only
admin_full_accessis pointed at by row id; the other seven have per-organizationcopies" does not follow — having a per-organization copy does not mean the grants point at it.
per-organization-catalog.tsalready warns in its own header that these rows are grant TARGETSand 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.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
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.
symptom today is a mis-targeted foreign key, not a denied request.
Suggested shape
Thread
organizationIdintoresolvePermissionSetIdand route it throughresolveOwnOrganizationRow(the governed spelling), keying the cache on(name, organizationId)rather thanname. Undersingleposture the unscoped answer stayscorrect 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