diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 2926688427..e45a2c6a39 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -634,8 +634,21 @@ export const GRANT_STANDING_KEYS = [ * to say so: `resolveAuthzContext` derived `platform_admin` from the name * alone and read no flag. It now drops a DEACTIVATED set before any * derivation, so `active: false` on `admin_full_access` un-makes every - * platform admin at once — the same end state as renaming or deleting the - * row, reached by a payload that touches neither. Enforcing the flag without + * GRANT-anchored platform admin at once — the same end state as renaming or + * deleting the row, reached by a payload that touches neither. ⚠️ Not + * "every platform admin": since the #11663 re-anchor (L2) standing has a + * SECOND anchor this write cannot reach — a config-anchored administrator + * (a declared `OS_PLATFORM_OWNER_EMAIL` address on a VERIFIED `sys_user` + * row) is derived at `resolve-authz-context.ts` §6b-config without + * consulting the set row or its `active` flag at all, and carries the + * shipped `ADMIN_FULL_ACCESS_CAPABILITIES` envelope rather than the stored + * set's. That is deliberately NOT a reason to drop `active` from this + * list: the write can still empty the GRANT anchor, which on every + * deployment that has declared no administrator emails is the whole + * population. Listing it is an over-approximation in the SAFE direction — + * it can cost an enumeration on a write that turns out to change no count, + * never the reverse — and taking it out would be a behaviour change, not a + * comment fix. Enforcing the flag without * listing it here would have left exactly one unguarded route to an * installation-wide lockout: the action is offered on every row with no * visibility or condition guard, the seeders deliberately never reconcile @@ -645,13 +658,16 @@ export const GRANT_STANDING_KEYS = [ * Everything else a permission-set write touches (`label`, `description`, the * four permission JSON blobs, provenance) is still invisible to "who is an * administrator" — `resolveAuthzContext` derives `platform_admin` from the NAME - * of an ACTIVE set, never from the capabilities it carries — so those writes - * still cost this guard no reads at all. Adding `active` does not walk that - * back: the projection is FACETS ONLY and deliberately never re-flips a - * record's on/off switch (`permission-set-projection.ts`, #4669), so every - * projection pass, every `os meta resync` and every ordinary Setup edit still - * misses this list entirely. What now pays for an enumeration is the write that - * actually toggles the switch — which is the write this list exists to judge. + * of an ACTIVE set or, since the #11663 re-anchor (L2), from the + * deployment-config anchor, and from the capabilities of neither (the config + * arm's envelope is the shipped `ADMIN_FULL_ACCESS_CAPABILITIES` declaration, + * not the stored row) — so those writes still cost this guard no reads at all. + * Adding `active` does not walk that back: the projection is FACETS ONLY and + * deliberately never re-flips a record's on/off switch + * (`permission-set-projection.ts`, #4669), so every projection pass, every + * `os meta resync` and every ordinary Setup edit still misses this list + * entirely. What now pays for an enumeration is the write that actually + * toggles the switch — which is the write this list exists to judge. * * `id` is deliberately NOT here even though the enumeration reads it. On this * engine `data.id` on an update ADDRESSES the row (it is what @@ -661,9 +677,12 @@ export const GRANT_STANDING_KEYS = [ * * `sys_position` gets no analogous list because it has no route into this * enumeration to guard: platform-admin standing is read from UNSCOPED - * `sys_user_permission_set` grants only (a position-bound `admin_full_access` - * never conferred it, in the resolver or here), and org-administrator standing - * is read from `sys_member.role`. Deactivating a position cannot empty either. + * `sys_user_permission_set` grants and from the deployment-config anchor (a + * declared `OS_PLATFORM_OWNER_EMAIL` address on a VERIFIED `sys_user` row, + * which `USER_STANDING_KEYS` below guards) — a position-bound + * `admin_full_access` reaches neither, in the resolver or here — and + * org-administrator standing is read from `sys_member.role`. Deactivating a + * position cannot empty any of them. */ export const PERMISSION_SET_STANDING_KEYS = ['name', 'active'] as const; diff --git a/packages/types/src/email-verified.ts b/packages/types/src/email-verified.ts index 5d6d5290a8..4be39ea3c3 100644 --- a/packages/types/src/email-verified.ts +++ b/packages/types/src/email-verified.ts @@ -10,16 +10,34 @@ * column as verified would re-open the exact hole this predicate closes for * every row that predates the column. * - * ONE resolution, two consumers, by design (#12751): the walled - * platform-admin elevation gate (`plugin-security` - * `bootstrapPlatformAdmin`, where the check REFUSES an unverified owner - * match) and the walled owner-verification boot diagnostic (`plugin-auth` - * `walled-owner-verification-path.ts`, where the check decides whether the - * declared owner's account is already past needing a verification path). - * Those two must answer "is this row verified?" identically — a drift means - * a boot warning that forecasts a refusal the gate will not make, or stays - * silent about one it will. `@objectstack/types` is the shared home both - * packages already resolve `OS_PLATFORM_OWNER_EMAIL` from (`env.ts`). + * ONE resolution, several consumers, by design (#12751) — and since the + * #11663 platform-admin re-anchor (leg L4) the walled platform-admin + * ELEVATION GATE this paragraph used to name first is RETIRED: under a + * walled posture `bootstrapPlatformAdmin` writes no grant row and elevates + * nobody, it reports. Standing is derived PER REQUEST instead — from a + * config-anchored verified email, or the legacy unscoped grant row — so the + * consumer set now includes the authorization derivation itself: + * + * - `matchesConfiguredPlatformAdmin` (`@objectstack/core` + * `security/platform-admin.ts`), read at the one derivation site + * (`resolve-authz-context.ts` §6b-config), where an UNVERIFIED account + * holding a declared address confers nothing — and, through it, + * `plugin-auth`'s last-admin guard, whose administrator enumeration must + * answer the same question the resolver does; + * - `resolvePlatformAdminStanding` (`plugin-security` + * `platform-admin-service.ts`), the read-only standing/audit answer the + * walled boot reports from, and `isVerifiedPlatformOwnerRow` beside it + * (`platform-owner-wall-bypass.ts`), the Layer 0 wall bypass; + * - the walled owner-verification boot diagnostic (`plugin-auth` + * `walled-owner-verification-path.ts`, where the check decides whether the + * declared owner's account is already past needing a verification path). + * + * They must all answer "is this row verified?" identically — a drift is no + * longer just a boot warning forecasting a refusal that will not be made, it + * is a diagnostic, an audit surface or a guard disagreeing with who actually + * resolves PLATFORM_ADMIN on the next request. `@objectstack/types` is the + * shared home every one of those packages already resolves + * `OS_PLATFORM_OWNER_EMAIL` from (`env.ts`). */ export function isEmailVerifiedUserRow(row: unknown): boolean { const v = (row as { email_verified?: unknown } | null | undefined)?.email_verified;