From 4af7d396240e560b76284dc534f0cfa58d211477 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:30:13 +0000 Subject: [PATCH 1/7] wip(security): the single-posture promotion grant is owned by the Default Organization, and the resolver and holder scans read it Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- .../src/security/resolve-authz-context.ts | 150 ++++++++++------ .../src/ensure-default-organization.ts | 41 +++-- .../src/bootstrap-platform-admin.ts | 163 +++++++++++++----- 3 files changed, 254 insertions(+), 100 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index b019c2d508e..e9745652878 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -46,8 +46,8 @@ import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; // second copy of a security-relevant classifier exist. See `tryFind`'s catch. // // [#13667 / #11663 L5] `resolveTenancyPosture` is the REQUESTED tenancy posture. It -// is read at exactly one place below (§6b) to key the LEGACY unscoped-grant anchor -// to the same walled postures the BOOT-side detector already keys on — the same +// is read at exactly one place below (§6) to key the grant anchor of platform standing +// ({@link grantAnchorsPlatformStanding}) to the same walled postures the BOOT-side detector already keys on — the same // module, the same function, the same fail-stricter direction that detector reads. // It asks the ENVIRONMENT, never the engine, so the gate adds no query. import { isMissingTableError, resolveTenancyPosture } from '@objectstack/types'; @@ -109,7 +109,7 @@ export interface ResolvedAuthzContext { /** * [ADR-0095 D2/D3] The monotonic posture rung this principal resolves to, * DERIVED once here from held capability grants (never a better-auth role): - * `PLATFORM_ADMIN` (an unscoped `admin_full_access` grant, or — since #11663 + * `PLATFORM_ADMIN` (the anchoring `admin_full_access` grant, or — since #11663 * L2 — a VERIFIED `sys_user.email` on the deployment's declared * administrator list) > `TENANT_ADMIN` * (`organization_admin`) > `MEMBER` (the authenticated floor). `EXTERNAL` is @@ -895,6 +895,52 @@ function grantOrganizationOf(row: unknown): string | null { return String(value); } +/** + * [ADR-0131 D5] Does a stored grant ANCHOR platform-admin standing — the one + * predicate, with the posture as its parameter, that §6b asks of every + * `admin_full_access` grant: + * + * - a walled posture (`group` / `isolated`): no grant anchors standing; it is + * config-derived (`OS_PLATFORM_OWNER_EMAIL`, §6b-config) and nothing else + * (#11663 L5); + * - `single`: the grant the first-user promotion writes, which is owned by the + * Default Organization ({@link readDefaultOrganizationId}), and the + * organization-less row that promotion wrote before it was. + * + * Any other organization's grant of the set confers the set, never standing. + */ +function grantAnchorsPlatformStanding( + grant: unknown, + anchor: { walled: boolean; defaultOrganizationId: string | undefined }, +): boolean { + if (anchor.walled) return false; + const organizationId = grantOrganizationOf(grant); + return organizationId === null + || (anchor.defaultOrganizationId !== undefined && organizationId === anchor.defaultOrganizationId); +} + +/** + * [ADR-0131 D3/D5] The deployment's Default Organization: the one with + * `slug = 'default'`, else the only organization there is; `undefined` when + * neither exists (several organizations, none of them `default`, name none). + * The definition plugin-auth's boot invariant (`default-organization-invariant.ts`) + * creates the organization by, read-only here — and the one plugin-security's + * promotion resolves the owner of the grant it writes by, so the row it writes + * is the row this resolver reads as the anchor. + * + * A composition that registers no organization object holds none, and is not + * read: the engine refuses an unregistered name as "object not found". + */ +async function readDefaultOrganizationId(ql: any): Promise { + if (!registryHoldsObject(ql, SystemObjectName.ORGANIZATION)) return undefined; + const idOf = (row: any): string | undefined => + row?.id === undefined || row?.id === null || String(row.id) === '' ? undefined : String(row.id); + const bySlug = await tryFind(ql, SystemObjectName.ORGANIZATION, { slug: 'default' }, 1); + if (idOf(bySlug[0])) return idOf(bySlug[0]); + const all = await tryFind(ql, SystemObjectName.ORGANIZATION, {}, 2); + return all.length === 1 ? idOf(all[0]) : undefined; +} + /** The activation ledger's catalog types the resolver honours (ADR-0126 §3 regime C, as ADR-0131 D6 amends it). */ const LEDGER_CATALOG_TYPES = ['position', 'permission'] as const; @@ -1426,7 +1472,42 @@ export async function resolveUserAuthzGrants( // positions' — never a catalog row's `active` column. // (read in Leg 1 above) const upsRows = upsRowsAll.filter((r) => isGrantActive(r, nowMs)); - const applyingGrants = upsRows.filter((r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId)); + // [ADR-0131 D5 / #11663 L5] The platform-standing ANCHOR of a stored grant — one + // derivation site, with the posture as its parameter ({@link grantAnchorsPlatformStanding}): + // + // - WALLED: no grant anchors standing. The walled half of the legacy anchor is + // retired, so on a walled rig platform standing is config-derived + // (`OS_PLATFORM_OWNER_EMAIL`, §6b-config) and nothing else. + // - `single` — the DEFAULT, what a deployment that configured no tenancy at all + // resolves to: the `admin_full_access` grant `bootstrapPlatformAdmin` mints for + // the first human user, which D5 has OWNED BY THE DEFAULT ORGANIZATION (#11979; + // maintainer ruling A on #18707 keeps the zero-config promotion and moves only the + // row's owner), and the organization-less row that promotion wrote before it was — + // the row D10's ceremony attributes to that same organization. ⛔ Reading only the + // organization-less row would leave every promoted `single` rig with NO platform + // administrator once its grant is the Default Organization's. + // + // The REQUESTED posture is the input, not the effective one — the same source and the + // same fail-stricter direction the boot side reads: a deployment that ASKED for a wall + // stays walled here even while running degraded (`OS_ALLOW_DEGRADED_TENANCY=1`). + // `resolveTenancyPosture()` asks the ENVIRONMENT, so the posture costs no query; the + // Default Organization is read only for a principal that holds an organization-owned + // `admin_full_access` grant under `single`, so a principal holding none issues exactly + // the reads it issued before (the batch-equivalence goldens are unchanged). + const walled = postureEnforcesWall(resolveTenancyPosture()); + const defaultOrganizationId = + !walled && upsRows.some((r) => grantSetNameOf(r) === ADMIN_FULL_ACCESS && grantOrganizationOf(r) !== null) + ? await readDefaultOrganizationId(ql) + : undefined; + const platformAnchor = { walled, defaultOrganizationId }; + const anchorsPlatformStanding = (grant: unknown): boolean => + grantSetNameOf(grant) === ADMIN_FULL_ACCESS && grantAnchorsPlatformStanding(grant, platformAnchor); + // An anchoring grant applies in whichever organization the principal acts in, as the + // organization-less row it replaces always did: platform standing is the deployment's, + // never one tenant's. + const applyingGrants = upsRows.filter( + (r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId) || anchorsPlatformStanding(r), + ); const catalog = securityCatalogReaderOf(ql); const grantSetNames: string[] = []; for (const grant of applyingGrants) { @@ -1436,30 +1517,6 @@ export async function resolveUserAuthzGrants( // Position-bound sets (§6a), by name, in the order the held positions name them. const boundSetNames: string[] = []; let disabled = { positions: new Set(), permissions: new Set() }; - // [#11663 L5] …and under a WALLED posture it no longer is. This is the EXIT of - // the migration window L4 opened: the walled half of the legacy unscoped anchor - // is RETIRED, so on a walled rig platform standing is config-derived - // (`OS_PLATFORM_OWNER_EMAIL`, §6b-config) and nothing else. Read ADR-0131 D5 as - // amended 2026-09-17 (#18413) before widening this: the anchor retires here for - // the WALLED half only. - // - // ⛔ The `single` posture is deliberately NOT gated, and this is the whole of why - // the flag exists rather than a plain deletion. Under `single` — the DEFAULT, what - // a deployment that configured no tenancy at all resolves to — `bootstrapPlatformAdmin` - // MINTS this very row for the first human user, and that promotion is ruled correct - // and unchanged (Choice 4A, #11974; maintainer 2026-09-08, recorded in ADR-0131's 2026-09-17 amendment, verbatim: "The - // rest of Choice 4A (#11974, 2026-08-25) stands: retiring the walled write must not - // retire the `single` one"). A development environment started for a moment cannot be - // asked to declare an administrator first, so deleting the row route for every posture - // would leave every zero-config rig with NO platform administrator at all. The - // `single` half's own disposition is Choice 4B (#11979) — ⛔ a different card. - // - // The REQUESTED posture is the input, not the effective one — the same source and the - // same fail-stricter direction the boot side reads: a deployment that ASKED for a wall - // stays walled here even while running degraded (`OS_ALLOW_DEGRADED_TENANCY=1`). - // `resolveTenancyPosture()` asks the ENVIRONMENT, so this gate issues no query and the - // pinned batch-equivalence query multiset cannot move whichever way it answers. - const legacyGrantAnchorRetired = postureEnforcesWall(resolveTenancyPosture()); let hasPlatformAdminGrant = false; // 5b. [ADR-0090 D5] Audience anchor: every AUTHENTICATED member implicitly @@ -1526,28 +1583,23 @@ export async function resolveUserAuthzGrants( if (!name) continue; const body = await catalogSetBody(catalog, name); if (!body) continue; - const ownGrant = grantOrganizationOf(grant) !== null; const active = !disabled.permissions.has(name); take(body, active); - // platform_admin (ADR-0068 D2) is DERIVED from an UNSCOPED admin_full_access + // platform_admin (ADR-0068 D2) is DERIVED from the anchoring admin_full_access // USER grant — the single source of truth (no trusted stored boolean). The - // set is the catalog's one `admin_full_access` (ADR-0131 D4); an - // organization's grant of it confers the set, never platform standing. + // set is the catalog's one `admin_full_access` (ADR-0131 D4); a grant the + // anchor does not read (any other organization's, and every grant under a + // wall) confers the set, never platform standing. // // [ADR-0049] A DEACTIVATED set confers no standing either: a deactivated // `admin_full_access` must not keep conferring PLATFORM_ADMIN. // - // [#11663 L5] `legacyGrantAnchorRetired` gates the ANCHOR, not the grant: the - // set's own name and capabilities are pushed below exactly as for any other - // held set, on every posture. What a walled rig stops deriving from the - // grant is PLATFORM_ADMIN STANDING — the rung, the built-in - // `platform_admin` position and everything downstream of them. - if ( - !ownGrant - && body.name === ADMIN_FULL_ACCESS - && active - && !legacyGrantAnchorRetired - ) { + // [#11663 L5] The posture gates the ANCHOR, not the grant: the set's own name + // and capabilities are pushed below exactly as for any other held set, on + // every posture. What a walled rig stops deriving from the grant is + // PLATFORM_ADMIN STANDING — the rung, the built-in `platform_admin` position + // and everything downstream of them. + if (anchorsPlatformStanding(grant) && active) { hasPlatformAdminGrant = true; } } @@ -1582,8 +1634,8 @@ export async function resolveUserAuthzGrants( // ADDITIVE, never subtractive: nothing above is revoked here (design §5 // step 3). A deployment that has declared no administrators resolves // exactly as it did — `platformAdminConfig.emails` is empty and the branch - // short-circuits before it looks at any row. Under `single` the legacy grant - // read above then remains the only anchor; under a WALLED posture [#11663 L5] + // short-circuits before it looks at any row. Under `single` the grant anchor + // read above (§6) then remains the only anchor; under a WALLED posture [#11663 L5] // that read is retired, so an undeclared walled rig has NO platform // administrator — which is the fail-closed answer the boot-side backstop in // `plugin-security` announces at startup. @@ -1622,7 +1674,7 @@ export async function resolveUserAuthzGrants( // 6d. [ADR-0095 D2/D3] Resolve the posture rung ONCE, from held CAPABILITY // grants — never from a better-auth role. `PLATFORM_ADMIN` from the - // unscoped `admin_full_access` grant (the same `viewAllRecords`/ + // anchoring `admin_full_access` grant (the same `viewAllRecords`/ // `modifyAllRecords` evidence the superuser bypass trusts); `TENANT_ADMIN` // from the `organization_admin` grant (auto-provisioned from the better- // auth owner/admin role at §3 above — a provisioning source, not an @@ -1668,7 +1720,9 @@ export async function resolveUserAuthzGrants( * * ADR-0068 D2 defined PLATFORM standing as one thing: an UNSCOPED * (`organization_id = null`) `sys_user_permission_set` grant on the - * `admin_full_access` set, held **now**. Since #11663 L2 there is a SECOND + * `admin_full_access` set, held **now** — under `single` since ADR-0131 D5 the + * grant owned by the Default Organization, beside that legacy row (§6, + * {@link grantAnchorsPlatformStanding}). Since #11663 L2 there is a SECOND * anchor beside it — a `sys_user` row whose VERIFIED email is on the * deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) — and * this predicate answers for both, for free, because it is a projection rather @@ -1679,7 +1733,7 @@ export async function resolveUserAuthzGrants( * * It is a PROJECTION of {@link resolveUserAuthzGrants}, never a second * derivation: the answer is the `PLATFORM_ADMIN` rung of the posture ladder, - * and that rung is derived from the unscoped-grant evidence and nothing else. + * and that rung is derived from the anchoring-grant evidence and nothing else. * Everything that governs those grants therefore applies here by construction * and cannot drift from it — the ADR-0091 validity window (§6), the ADR-0049 * `active` flag on the catalogue row (§6b), the system-identity read, and the diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index ca28891052d..9bf10531f6d 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -34,11 +34,12 @@ * `plugin-security`'s `resolvePlatformAdminStanding` serves the audit * surface with, so the account this helper binds is the account the * audit surface reports as holding standing. - * 2. **Legacy grant anchor** (Choice 4A), **under a NON-WALLED posture - * only**: the oldest unscoped `sys_user_permission_set` row on - * `admin_full_access`. This is what still anchors `single`-posture - * deployments — first-user promotion keeps writing the grant row there, - * by ruling, and #11979 is the card that disposes of it. + * 2. **Grant anchor** (Choice 4A), **under a NON-WALLED posture + * only**: the oldest `sys_user_permission_set` row on `admin_full_access` + * owned by the Default Organization, or by none (a row written before + * ADR-0131 D5 moved its owner). This is what still anchors + * `single`-posture deployments — first-user promotion keeps writing the + * grant row there, by ruling, owned by the Default Organization (#11979). * * ⛔ Under a walled posture this anchor is RETIRED. P5's honoured * migration window closed with the legacy-grant removal leg (design §5 @@ -85,6 +86,7 @@ import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; import { permissionSetInEffect } from './catalog-set-in-effect.js'; +import { resolveDefaultOrgId } from './tenancy-service.js'; interface BootstrapLogger { info: (message: string, meta?: Record) => void; @@ -443,8 +445,9 @@ export async function ensureDefaultOrganization( // exactly as before (Choice 4A). let adminUserId: string | undefined = await findConfigAnchoredAdminUserId(ql); - // 2. LEGACY grant anchor (oldest cross-tenant `admin_full_access` grant) — - // still what anchors `single` posture (Choice 4A; #11979 disposes of it), + // 2. GRANT anchor (oldest `admin_full_access` grant owned by the Default + // Organization or by none) — what anchors `single` posture (Choice 4A; + // ADR-0131 D5 owns the promotion's row by the Default Organization), // and ⛔ RETIRED under a wall [#11663 L5]. // // The key is the derivation site's own expression, read from the @@ -489,12 +492,24 @@ export async function ensureDefaultOrganization( // confers nothing. The answer is then `no_admin`, which decides nothing // (`default-org-bootstrap-once.ts` records no decision on it), so a later // trigger binds once the grant names its set. - const adminGrants = await tryFind( - ql, - 'sys_user_permission_set', - { [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: null }, - 50, - ); + // + // [ADR-0131 D5] …owned by the Default Organization, which is the owner + // `single`'s first-user promotion writes, or by no organization, which is + // the row an earlier release's promotion wrote before D10's ceremony + // attributed it — the two the derivation site reads as the anchor + // (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`). Reading only + // the organization-less row would answer `no_admin` on every deployment + // promoted since, and never bind its administrator. + const defaultOrgId = await resolveDefaultOrgId(ql); + const adminGrants: any[] = []; + for (const organizationId of defaultOrgId ? [null, defaultOrgId] : [null]) { + adminGrants.push(...await tryFind( + ql, + 'sys_user_permission_set', + { [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: organizationId }, + 50, + )); + } if (adminGrants.length === 0) { return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index d1677dac6d5..f9123b5ffce 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -8,10 +8,16 @@ * 2026-08-23; re-anchored by #11663, maintainer acceptance 2026-08-25, * leg L4 = #11974): * - `single`: promote the first registered human user by inserting a - * `sys_user_permission_set` row pointing at `admin_full_access` with - * `organization_id = NULL`. Unchanged — Choice 4A keeps first-user - * promotion and its grant row for this posture (4B is the sequenced - * follow-up, not dropped). [#14348] "First user" means the oldest + * `sys_user_permission_set` row pointing at `admin_full_access`, + * **owned by the Default Organization** (ADR-0131 D5; #11979 under the + * maintainer's ruling A on #18707): the zero-config promotion stays, + * and ⛔ no NULL-organization grant row is written. The holder scan + * and the derivation site (`resolve-authz-context.ts` §6) read that + * row — and the organization-less row an earlier release wrote, until + * D10's ceremony attributes it to the same organization. A deployment + * whose Default Organization cannot be named is not promoted: it says + * so, loudly, rather than writing the row without an owner. + * [#14348] "First user" means the oldest * human that can AUTHENTICATE (holds a `sys_account`), not the oldest * `sys_user` row: an app declaring people in `defineStack({ data })` * stores credential-less directory rows that are always older than any @@ -76,6 +82,7 @@ import { serializePlatformAdminStandingSnapshot, } from './platform-admin-standing-audit.js'; import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; +import { resolveDefaultOrganizationId } from './suggested-audience-bindings.js'; /** * The order the standing-audit read states TO THE DRIVER. @@ -453,17 +460,34 @@ export function shouldReplayBootstrapFor(opCtx: { } /** - * The permission set whose unscoped grant makes a user the platform admin under - * `single`. Named once so the bootstrap's guard and + * The permission set whose anchoring grant makes a user the platform admin under + * `single` (the Default Organization's, ADR-0131 D5). Named once so the bootstrap's guard and * {@link findExistingPlatformAdmin} cannot ask about two different sets. */ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; /** - * Who ALREADY holds the unscoped platform-admin grant (commit 1c83ca226) — the + * Who ALREADY holds the platform-admin grant (commit 1c83ca226) — the * one rule both {@link bootstrapPlatformAdmin}'s `already_have_admin` guard and * {@link findExistingPlatformAdmin} answer with. * + * ## Whose grant is the anchor ([ADR-0131] D5) + * + * Under `single` the promotion writes its grant OWNED BY THE DEFAULT + * ORGANIZATION (`defaultOrganizationId`), so that row is a holder; so is the + * organization-less row an earlier release's promotion wrote, until D10's + * ceremony attributes it to the same organization. The derivation site reads + * the same two (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`), + * so the holder this scan finds is the account the resolver grades + * PLATFORM_ADMIN — ⛔ a scan reading only the NULL row would find nobody on a + * promoted deployment the ceremony has run on and mint a SECOND grant on every + * boot. A user holding both rows is one holder; whichever row sorts first by + * `id` answers. Any other organization's grant of the set is an organization + * administrator's and never a holder. `defaultOrganizationId` is `undefined` + * under a wall, where only the legacy organization-less row is asked about (and + * only to report it — it anchors nothing there), and where the Default + * Organization cannot be named. + * * The read was once `tryFind(ql, 'sys_user_permission_set', { * permission_set_id: adminPsId }, 50)` — no `orderBy`, cap 50 — with the * predicate that actually decides (`!organization_id`) applied CLIENT-SIDE to @@ -515,7 +539,7 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * ## Several holders * * Leg A's rows arrive ordered by grant-row `id` ({@link ADMIN_GRANT_SCAN_ORDER}) - * and the FIRST unscoped human row wins, so with two platform admins the answer + * and the FIRST anchoring human row wins, so with two platform admins the answer * is the holder of the grant row whose `id` sorts first — deterministic on every * driver that honours the order, and the same answer for every caller. * @@ -535,7 +559,7 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * --apply`, ADR-0131 D10). A by-name scan alone would answer "nobody" for such * a grant, promote the oldest user and hand them the seeded records: the * second grant this function exists to refuse. So leg C, only when A and B - * found nobody, reaches the unscoped human grants that name NOTHING, and one + * found nobody, reaches the anchoring human grants that name NOTHING, and one * comes back as `unnamedHolder`. It only ever RESTRICTS: the bootstrap * withholds the promotion on it, and it is never a holder — never an * `adminUserId`, never a claim target ({@link findExistingPlatformAdmin}). @@ -550,15 +574,17 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; async function findPlatformAdminGrantHolder( ql: any, reachUnnamed: boolean, + defaultOrganizationId: string | undefined, ): Promise<{ holder: any | undefined; - /** Leg C: an unscoped human grant that names no set yet (restricts only). */ + /** Leg C: an anchoring human grant that names no set yet (restricts only). */ unnamedHolder: any | undefined; adminGrantRowsExamined: number; truncated: boolean; }> { - const isUnscopedHumanHolder = (r: any) => - !r.organization_id && r.user_id !== SystemUserId.SYSTEM; + const isAnchorHumanHolder = (r: any) => + (!r.organization_id || (defaultOrganizationId !== undefined && r.organization_id === defaultOrganizationId)) + && r.user_id !== SystemUserId.SYSTEM; // Counted by row IDENTITY, not by read: the two legs overlap by construction // (leg A's rows are a subset of leg B's population), and a number that @@ -574,16 +600,22 @@ async function findPlatformAdminGrantHolder( }; let truncated = false; - // Leg A — the narrow question, asked of the driver. - const unscopedGrantRows = await tryFind( - ql, - 'sys_user_permission_set', - { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: null }, - PLATFORM_ADMIN_GRANT_PAGE_SIZE, - ADMIN_GRANT_SCAN_ORDER, - ); - countExamined(unscopedGrantRows); - let holder: any | undefined = unscopedGrantRows.find(isUnscopedHumanHolder); + // Leg A — the narrow question, asked of the driver: once per anchoring owner + // (the organization-less row, and the Default Organization's), each answer + // ordered by `id`, and the row whose `id` sorts first of the two wins. + let holder: any | undefined; + for (const organizationId of defaultOrganizationId === undefined ? [null] : [null, defaultOrganizationId]) { + const anchorGrantRows = await tryFind( + ql, + 'sys_user_permission_set', + { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: organizationId }, + PLATFORM_ADMIN_GRANT_PAGE_SIZE, + ADMIN_GRANT_SCAN_ORDER, + ); + countExamined(anchorGrantRows); + const found = anchorGrantRows.find(isAnchorHumanHolder); + if (found && (!holder || String(found.id) < String(holder.id))) holder = found; + } /** * The ordered, bounded scan of one grant population, stopping at the first @@ -607,7 +639,7 @@ async function findPlatformAdminGrantHolder( // Leg B — the ordered, bounded scan that still applies the exact predicate. if (!holder) { - holder = await scan({ [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME }, isUnscopedHumanHolder); + holder = await scan({ [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME }, isAnchorHumanHolder); } // Leg C — the grants that name nothing yet; it restricts only (see the @@ -616,7 +648,7 @@ async function findPlatformAdminGrantHolder( if (!holder && reachUnnamed) { unnamedHolder = await scan( { [GRANT_SET_NAME_FIELD]: null }, - (r) => grantSetNameOf(r) === undefined && isUnscopedHumanHolder(r), + (r) => grantSetNameOf(r) === undefined && isAnchorHumanHolder(r), ); } @@ -640,9 +672,9 @@ async function findPlatformAdminGrantHolder( * the bootstrap never promotes, so no claim ever ran under a wall; * - no {@link PLATFORM_ADMIN_PERMISSION_SET_NAME} among the sets this plugin * ships (`admin_permission_set_missing`); - * - nobody holds the unscoped grant yet — a first boot, where the promotion + * - nobody holds the anchoring grant yet — a first boot, where the promotion * that follows does its own claim; - * - [ADR-0131 D4] the only unscoped grant of the set names nothing yet + * - [ADR-0131 D4] the only anchoring grant of the set names nothing yet * (`unnamedHolder`): it restricts the promotion and confers nothing, so it * names no claim target — the claim of a later boot, once the backfill has * named the grant, hands the rows over. @@ -661,10 +693,31 @@ export async function findExistingPlatformAdmin( } // [ADR-0131 D4] By name alone: leg C (the legacy id reach) only restricts // and never names a holder, so this question needs no catalog row. - const { holder } = await findPlatformAdminGrantHolder(ql, false); + const { holder } = await findPlatformAdminGrantHolder(ql, false, await readPromotionGrantOwner(ql)); return holder?.user_id ? String(holder.user_id) : undefined; } +/** + * [ADR-0131 D5] The owner of the grant the `single` promotion writes, and of + * the grant the holder scan reads beside the organization-less one: the + * Default Organization, by the definition plugin-auth's boot invariant creates + * it by (`resolveDefaultOrganizationId` — `slug = 'default'`, else the only + * organization) and the derivation site reads it by (`resolve-authz-context.ts` + * `readDefaultOrganizationId`), so the row written is the row that confers. + * + * `undefined` when it cannot be named — no organization, several and none of + * them `default`, a composition that registers no organization object, or a + * read that failed. A failed read names nothing rather than guessing: the + * promotion then withholds and says why, and its next pass reads again. + */ +async function readPromotionGrantOwner(ql: any): Promise { + try { + return await resolveDefaultOrganizationId(ql); + } catch { + return undefined; + } +} + /** * Answer the posture-keyed platform-admin question: promote the first * registered human user under `single`, report config-derived standing (and @@ -731,7 +784,8 @@ export async function bootstrapPlatformAdmin( // verbatim: 「1509 选择 env 指定 owner 邮箱」; re-anchored by #11663 L4): // // - `single`: first human user is promoted — ruled reasonable, unchanged - // (Choice 4A keeps first-user promotion and its grant row). [#14348] + // (Choice 4A keeps first-user promotion and its grant row, which ADR-0131 + // D5 has owned by the Default Organization). [#14348] // "first human user" = the oldest one that can authenticate. // - walled (`group` / `isolated`): NO grant row is written. Standing is // config-derived at the one derivation site (`resolve-authz-context.ts` @@ -746,6 +800,12 @@ export async function bootstrapPlatformAdmin( // fail toward the stricter reading, same direction ADR-0093 D5 fails. const walled = postureEnforcesWall(resolveTenancyPosture()); + // [ADR-0131 D5] Under `single` the promotion's grant is the Default + // Organization's, so that is the owner the holder scan asks about beside the + // organization-less row and the owner a promotion writes. Under a wall no + // grant anchors standing and none is written, so nothing is asked. + const defaultOrganizationId = walled ? undefined : await readPromotionGrantOwner(ql); + // [ADR-0131 D4] The grant names `admin_full_access`; the set is the catalog // definition this plugin ships, so no `sys_permission_set` row is needed to // grant it. The legacy row's id, when there is one, feeds only the @@ -761,11 +821,11 @@ export async function bootstrapPlatformAdmin( // ({@link findExistingPlatformAdmin}). Why it is two legs and why each is // bounded and ordered is on the function. const { - holder: unscopedHolder, + holder: anchorHolder, unnamedHolder, adminGrantRowsExamined, truncated: adminGrantScanTruncated, - } = await findPlatformAdminGrantHolder(ql, true); + } = await findPlatformAdminGrantHolder(ql, true, defaultOrganizationId); // ⛔ The truncation is never silent (commit 1c83ca226). Reaching the ceiling is the one // way this scan still answers "no platform admin yet" while one exists, and @@ -773,13 +833,13 @@ export async function bootstrapPlatformAdmin( // grant and hands it the seeded business records. So it says the number it // examined rather than letting the promotion below read as a statement about // the whole table. - if (adminGrantScanTruncated && !unscopedHolder && !unnamedHolder) { + if (adminGrantScanTruncated && !anchorHolder && !unnamedHolder) { const truncation = '[security] the existing-platform-admin check stopped at its ceiling of ' + `${PLATFORM_ADMIN_GRANT_SCAN_CEILING} admin_full_access grant row(s) ` - + `(${adminGrantRowsExamined} examined) without finding an unscoped human grant — rows beyond ` + + `(${adminGrantRowsExamined} examined) without finding a human platform-admin grant — rows beyond ` + 'that point were NOT examined, so this deployment may ALREADY have a platform administrator ' - + 'this boot did not see. Promoting now would mint a SECOND unscoped grant and re-own the seeded ' + + 'this boot did not see. Promoting now would mint a SECOND platform-admin grant and re-own the seeded ' + `business records to it. Name the intended administrator with ${PLATFORM_OWNER_EMAIL_ENV} rather ` + 'than leaving the answer to a scan bound.'; if (logger?.warn) logger.warn(truncation); @@ -795,7 +855,7 @@ export async function bootstrapPlatformAdmin( // Under walled postures the same row is no longer an anchor at all, so the // walled branch below does not take this early exit: it goes on to report // config-derived standing, or to refuse fail-closed when nobody is declared. - if (!walled && unscopedHolder) { + if (!walled && anchorHolder) { return { adminPromoted: false, reason: 'already_have_admin', @@ -803,7 +863,7 @@ export async function bootstrapPlatformAdmin( // re-run hands rows to this name — or, when it fires before this pass // has run at all, asks {@link findExistingPlatformAdmin}, which answers // with this same holder (see `adminUserId` above). - ...(unscopedHolder.user_id ? { adminUserId: String(unscopedHolder.user_id) } : {}), + ...(anchorHolder.user_id ? { adminUserId: String(anchorHolder.user_id) } : {}), ...grantScanCounts, }; } @@ -844,7 +904,7 @@ export async function bootstrapPlatformAdmin( // posture, so on these rigs standing is CONFIG-DERIVED and nothing else. // // ⛔ Nothing here writes, deletes or re-owns that row — its ownership is - // ADR-0131 C3's, on the v18 line. `unscopedHolder` therefore still means + // ADR-0131 C3's, on the v18 line. `anchorHolder` therefore still means // exactly what it meant: a row exists. What changed is what the row BUYS, // which under a wall is now nothing. @@ -879,9 +939,9 @@ export async function bootstrapPlatformAdmin( // be administered through it; under a wall that row stopped conferring // when the dual read retired, so saying only "declare somebody" would hide // the fact that somebody has just lost standing. - (unscopedHolder + (anchorHolder ? " This deployment also holds a legacy unscoped 'admin_full_access' grant row" + - ` (user ${String(unscopedHolder.user_id)}). Under a walled posture that row is NO LONGER` + + ` (user ${String(anchorHolder.user_id)}). Under a walled posture that row is NO LONGER` + ` an anchor for platform admin standing — the migration window announced since 17.3.0 has` + ` closed — so declaring that account's verified address in ${PLATFORM_OWNER_EMAIL_ENV} is` + ' what restores it. The row itself is left exactly as it is.' @@ -1019,12 +1079,37 @@ export async function bootstrapPlatformAdmin( chosen: any, audit: { basis: 'declared-owner' | 'oldest-authenticable'; pool: string; candidatePoolSize: number }, ) => { + // [ADR-0131 D5] ⛔ Never a NULL-organization grant: the row is the Default + // Organization's, and a deployment whose Default Organization cannot be + // named is not promoted. Writing the row without an owner would hand out + // the highest-privilege grant in the system as the one shape D5 retires; + // guessing an owner would hand it to an organization the derivation site + // does not read as the anchor. So it withholds and says why — at `error`, + // because the deployment boots on looking healthy with no administrator — + // and the next pass (every `kernel:ready`, every sign-up) promotes once the + // organization can be named. + if (defaultOrganizationId === undefined) { + const message = + `[security] platform-admin promotion WITHHELD for ${chosen.email ?? chosen.id}: the grant it writes ` + + 'is owned by the Default Organization (ADR-0131 D5), and this deployment names none — no ' + + "sys_organization row with slug 'default' and not exactly one organization (or the read was " + + 'refused). Under the single tenancy posture the auth plugin creates the Default Organization at ' + + 'boot; with several organizations, give the intended one the slug \'default\'. This deployment has ' + + 'NO platform administrator until then.'; + if (logger?.error) logger.error(message); + else logger?.warn?.(message); + return { + adminPromoted: false, + reason: 'default_organization_unresolved', + ...grantScanCounts, + }; + } const inserted = await tryInsert(ql, 'sys_user_permission_set', { id: genId('ups'), user_id: chosen.id, // [ADR-0131 D4] The grant names its set; no id is written (D10). permission_set: PLATFORM_ADMIN_PERMISSION_SET_NAME, - organization_id: null, + organization_id: defaultOrganizationId, granted_by: null, }); if (!inserted) { From 052aaf76246c35dbdd59d7b7b3461c91bc2aac09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:37:34 +0000 Subject: [PATCH 2/7] wip(auth,core): the last-admin guard and the standing surface read the Default Organization's grant Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- .../security/admin-standing-surface.test.ts | 21 +++ .../src/security/admin-standing-surface.ts | 36 +++- ...uthz-context.platform-admin-config.test.ts | 66 +++++++ .../plugin-auth/src/last-admin-guard.ts | 170 ++++++++++++++++-- 4 files changed, 267 insertions(+), 26 deletions(-) diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index de4aeb276f2..8472ffd8a57 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -268,6 +268,27 @@ const VARIANTS: Record< }, }, + // [ADR-0131 D5] The `single` promotion's grant, owned by the Default + // Organization: the only variant in which that organization's row is read, + // so the union needs it — without it `sys_organization.id` would be absent + // from the declaration and the guard would stop judging the delete that + // moves the anchor off this grant. + 'default-organization-owned grant': { + org: 'org_1', + tables: { + sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 0 }], + sys_organization: [{ id: 'org_1', slug: 'default' }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'usr_1', permission_set: 'admin_full_access', organization_id: 'org_1' }, + ], + sys_permission_set: [], + }, + }, + // [#11663 L2] The CONFIG anchor. No grant row anywhere: standing comes from // the declared administrator list matched against this row's own `email`, // gated on `email_verified`. This is the only variant in which those two diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 896c9e2d156..5e6f4f4d78a 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -98,9 +98,9 @@ export interface AdminStandingTable { * ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it * authenticates a principal and seeds `permissions` with the key's scopes, and * confers no administrator standing of its own — `hasPlatformAdminGrant` is set - * from the catalog's `admin_full_access` set reached through an UNSCOPED - * `sys_user_permission_set` grant (§6b, on a NON-WALLED posture only since - * #11663 L5) or from the deployment config matched against the caller's own + * from the catalog's `admin_full_access` set reached through the anchoring + * `sys_user_permission_set` grant (§6b — the Default Organization's or an + * organization-less one, on a NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own * STORED `sys_user` row (§6b-config, on every posture), never from a scope * string and never from the caller-seedable `grants.email`. */ @@ -126,14 +126,17 @@ export const ADMIN_STANDING_SURFACE: Readonly sys_user_permission_set: { role: 'derives', reason: - 'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an UNSCOPED, ' - + 'in-window (ADR-0091) grant of `admin_full_access` (§6b). Re-pointing it, scoping it to an ' - + 'organization or moving it out of its window revokes the standing while leaving the row in ' - + 'place. ⚠️ Under a WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half ' + 'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an in-window ' + + '(ADR-0091) grant of `admin_full_access` owned by the Default Organization (ADR-0131 D5, the ' + + 'row the first-user promotion writes) or by no organization (the row it wrote before) — §6, ' + + '`grantAnchorsPlatformStanding`. Re-pointing it, scoping it to another organization or moving ' + + 'it out of its window revokes the standing while leaving the row in place. ⚠️ Under a ' + + 'WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half ' + 'of this legacy anchor is retired, so standing there comes from `OS_PLATFORM_OWNER_EMAIL` ' + '(§6b-config) and from nothing else, and this row is read only for the permission set it ' - + 'names. Under `single` — the default — the row is unchanged and still the anchor that ' - + "rig's zero-config first-user promotion mints. [ADR-0131 D4] The grant holds its set BY " + + 'names. Under `single` — the default — the row is still the anchor that rig\'s ' + + 'zero-config first-user promotion mints, owned by the Default Organization (see ' + + '`sys_organization`). [ADR-0131 D4] The grant holds its set BY ' + 'NAME (`permission_set`): a grant that names nothing confers nothing, and its ' + '`permission_set_id` is not read here.', columns: [ @@ -148,6 +151,21 @@ export const ADMIN_STANDING_SURFACE: Readonly ], }, + sys_organization: { + role: 'derives', + reason: + '[ADR-0131 D5] Names the Default Organization — `slug = \'default\'`, else the only ' + + 'organization (`readDefaultOrganizationId`) — whose `admin_full_access` grant anchors ' + + 'platform standing under `single` (§6). Read only for a principal holding an ' + + 'organization-owned grant of that set, and never under a wall. Re-slugging the Default ' + + 'Organization while several organizations exist, or deleting it, moves the anchor off its ' + + 'grant and revokes that standing with no grant row touched.', + columns: [ + 'id', + 'slug', + ], + }, + sys_member: { role: 'derives', reason: diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index dff332b5adf..4f3403f19be 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -655,3 +655,69 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg expect(seen[3]).toBe('MEMBER|'); }); }); + +/** + * [ADR-0131 D5] The `single` promotion's grant row is OWNED BY THE DEFAULT + * ORGANIZATION (#11979, maintainer ruling A on #18707), so the anchor reads that + * row — one derivation site, the posture its parameter. Before this, the anchor + * read the organization-less row only, so a promoted rig whose grant D10's + * ceremony (or the promotion itself) had given the Default Organization resolved + * a zero-admin deployment. + */ +describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_access grant is the anchor', () => { + const ownedBy = (organizationId: string, organizations: Array>) => ({ + ...legacyTables(), + sys_organization: organizations, + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'usr_1', permission_set: ADMIN_FULL_ACCESS, organization_id: organizationId }, + ], + }); + const DEFAULT_ONLY = [{ id: 'org_default', slug: 'default' }]; + + it('⭐ confers PLATFORM_ADMIN with no config at all — in the Default Organization and with no active organization', async () => { + for (const tenantId of ['org_default', undefined]) { + const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_default', DEFAULT_ONLY)), 'usr_1', { nowMs: NOW, tenantId }); + expect(grants.posture, String(tenantId)).toBe('PLATFORM_ADMIN'); + expect(grants.positions[0]).toBe('platform_admin'); + // The anchoring grant applies wherever the principal acts, as the + // organization-less row it replaces did: the set comes with the standing. + expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); + } + }); + + it('the sole organization is the Default Organization whatever its slug — the boot invariant\'s definition', async () => { + const grants = await resolveUserAuthzGrants( + makeQl(ownedBy('org_only', [{ id: 'org_only', slug: 'acme' }])), 'usr_1', { nowMs: NOW, tenantId: 'org_only' }, + ); + expect(grants.posture).toBe('PLATFORM_ADMIN'); + }); + + it('⛔ another organization\'s grant of the set confers the set in that organization, never standing', async () => { + const orgs = [{ id: 'org_default', slug: 'default' }, { id: 'org_b', slug: 'b' }]; + const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_b', orgs)), 'usr_1', { nowMs: NOW, tenantId: 'org_b' }); + expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); + expect(grants.posture).toBe('MEMBER'); + expect(grants.positions).not.toContain('platform_admin'); + }); + + it('⛔ several organizations and none of them `default` name no Default Organization, so nothing anchors', async () => { + const orgs = [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }]; + const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_a', orgs)), 'usr_1', { nowMs: NOW, tenantId: 'org_a' }); + expect(grants.posture).toBe('MEMBER'); + }); + + it('⛔ a WALLED rig derives nothing from the Default Organization\'s grant either, and does not ask which one it is', async () => { + requestPosture('isolated'); + const ql = makeQl(ownedBy('org_default', DEFAULT_ONLY)); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId: 'org_default' }); + expect(grants.posture).toBe('MEMBER'); + expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization'); + }); + + it('the organization-less row a pre-D5 promotion wrote still anchors, and asks no organization read', async () => { + const ql = makeQl({ ...legacyTables(), sys_organization: DEFAULT_ONLY }); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + expect(grants.posture).toBe('PLATFORM_ADMIN'); + expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index ebfaa0ca4e7..a3dba8cb842 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -78,9 +78,10 @@ * recovery path from inside the product once that happens. * * So the invariant is enforced at the WRITE, on the chokepoints every path goes - * through — `beforeUpdate` and `beforeDelete` on `sys_user`, `sys_member` and - * `sys_user_permission_set`, and `beforeInsert` / `beforeUpdate` on the - * activation ledger — rather than at any + * through — `beforeUpdate` and `beforeDelete` on `sys_user`, `sys_member`, + * `sys_user_permission_set` and `sys_organization` (which names the Default + * Organization), and `beforeInsert` / `beforeUpdate` on the activation + * ledger — rather than at any * individual endpoint. * HTTP-level guards protect only the endpoints they are attached to; these hold * for the admin ban / remove endpoints, `updateMemberRole`, the SCIM adapter @@ -127,10 +128,11 @@ * guard is unchanged and still counts both, so the definition stands on its own * here, enumerated in the opposite direction: * - * 1. **platform admin, grant-anchored** — an UNSCOPED - * (`organization_id = null`), in-window (ADR-0091) - * `sys_user_permission_set` grant of `admin_full_access`, **under a - * non-walled posture only**. This is the same evidence + * 1. **platform admin, grant-anchored** — an in-window (ADR-0091) + * `sys_user_permission_set` grant of `admin_full_access` owned by the + * Default Organization (ADR-0131 D5, the row the `single` promotion + * writes) or by none (the row it wrote before), **under a non-walled + * posture only**. This is the same evidence * `resolveAuthzContext` derives `platform_admin` from * (ADR-0068 D2 / ADR-0095 D3) — never a stored `sys_user.role` string — * and since #11663 L5 that derivation is posture-keyed, so this grade is @@ -398,7 +400,9 @@ type GuardedOp = | 'grant-delete' | 'activation-insert' | 'activation-update' - | 'user-standing-update'; + | 'user-standing-update' + | 'organization-update' + | 'organization-delete'; interface OpWords { /** Reads after "Refusing this …". */ @@ -490,6 +494,26 @@ const OP_WORDS: Record = { subject: 'accounts', table: SystemObjectName.USER, }, + // [ADR-0131 D5] The organization whose `admin_full_access` grant anchors + // platform standing under `single` is the Default Organization, named by its + // `slug` (or by being the only one) — so re-slugging or deleting it is a + // standing write. + 'organization-update': { + noun: 'organization change', + verb: 'change', + gerund: 'changing', + Verb: 'Change', + subject: 'organizations', + table: SystemObjectName.ORGANIZATION, + }, + 'organization-delete': { + noun: 'organization removal', + verb: 'remove', + gerund: 'removing', + Verb: 'Remove', + subject: 'organizations', + table: SystemObjectName.ORGANIZATION, + }, }; /** @@ -511,6 +535,14 @@ function standingOrigin(table: string, noun: string): string { 'the last thing an environment gives up, not the first.' ); } + if (table === SystemObjectName.ORGANIZATION) { + return ( + `The organization this ${noun} moves is this deployment's Default Organization (slug ` + + `'default', or its only organization), whose '${ADMIN_FULL_ACCESS}' grant is what makes ` + + 'its platform administrator one under the single tenancy posture. Grant another account ' + + 'administrator standing first, then make the change.' + ); + } if (table === SystemObjectName.USER) { return ( `If the ${noun} was a change of address or an email re-verification, the account it moves ` + @@ -713,6 +745,15 @@ export const USER_STANDING_KEYS = ['email', 'email_verified'] as const; */ export const ACTIVATION_LEDGER_STANDING_KEYS = ['metadata_type', 'name', 'active'] as const; +/** + * [ADR-0131 D5] Same, for `sys_organization`: the `slug` that names the Default + * Organization — the organization whose `admin_full_access` grant anchors + * platform standing under `single` (`slug = 'default'`, else the only + * organization). Re-slugging it while several organizations exist moves the + * anchor off its grant; a delete is judged on every row, like the other tables'. + */ +export const ORGANIZATION_STANDING_KEYS = ['slug'] as const; + /** * [commit f8eb73601] The three lists above, keyed by the table each one judges — the shape * the correspondence gate consumes. @@ -735,6 +776,7 @@ export const STANDING_KEYS_BY_TABLE: Readonly> [USER_PERMISSION_SET]: GRANT_STANDING_KEYS, [METADATA_ACTIVATION]: ACTIVATION_LEDGER_STANDING_KEYS, [SystemObjectName.USER]: USER_STANDING_KEYS, + [SystemObjectName.ORGANIZATION]: ORGANIZATION_STANDING_KEYS, }; /** @@ -789,6 +831,14 @@ export const STANDING_KEY_EXCLUSIONS: Readonly => { + const registry = (engine as { registry?: { getObject?: (name: string) => unknown } }).registry; + if (typeof registry?.getObject === 'function' && !registry.getObject(SystemObjectName.ORGANIZATION)) { + return undefined; + } + const fields = ['id', 'slug']; + const orgWrite = pending?.table === SystemObjectName.ORGANIZATION ? pending : undefined; + const addressed = async (rows: Array>) => { + if (!orgWrite || orgWrite.ids.size === 0) return rows; + const seen = new Set(rows.map((row) => toId(row.id))); + const missing = [...orgWrite.ids].filter((id) => !seen.has(id)); + if (missing.length === 0) return rows; + return [...rows, ...(await scan(op, SystemObjectName.ORGANIZATION, { where: { id: { $in: missing } }, fields }))]; + }; + const after = (rows: Array>) => + rows.map((row) => applyPending(row, pending, SystemObjectName.ORGANIZATION)) + .filter((row): row is Record => row !== undefined); + const bySlug = after(await addressed(await scan(op, SystemObjectName.ORGANIZATION, { where: { slug: 'default' }, fields }))) + .find((row) => row.slug === 'default' && toId(row.id)); + if (bySlug) return toId(bySlug.id); + // "The only organization" asks 0 / 1 / several, so it reads a page just wide + // enough to still tell several apart after the rows a pending delete removes. + const page = await engine.find( + SystemObjectName.ORGANIZATION, + { where: {}, fields, limit: 2 + (orgWrite?.ids.size ?? 0) }, + SYSTEM_READ, + ); + const remaining = after(Array.isArray(page) ? page : []); + return remaining.length === 1 ? toId(remaining[0]!.id) : undefined; + }; + /** * Every user this environment recognises as an administrator. * @@ -923,8 +1017,9 @@ export function registerLastAdminGuard( const ids = new Set(); const now = Date.now(); - // 1) Platform admins — unscoped, in-window `admin_full_access` grants, - // on a NON-WALLED posture only. + // 1) Platform admins — in-window `admin_full_access` grants owned by the + // Default Organization or by none (ADR-0131 D5), on a NON-WALLED + // posture only. // // [#11663 L5] The posture key is read from the environment with the same // expression the derivation site uses — `postureEnforcesWall( @@ -971,6 +1066,11 @@ export function registerLastAdminGuard( const links = await scan(op, USER_PERMISSION_SET, { where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS }, }); + // [ADR-0131 D5] Asked only when some grant names an organization: a + // deployment whose grants are all organization-less reads nothing more. + const defaultOrganizationId = links.some((l) => toId(l.organization_id ?? l.organizationId)) + ? await readDefaultOrganizationId(op, pending) + : undefined; for (const raw of links) { const link = applyPending(raw, pending, USER_PERMISSION_SET); // Revoked outright by the pending write. @@ -981,12 +1081,16 @@ export function registerLastAdminGuard( // [ADR-0131 D4] The name is the grant's one reference to its set, and // nothing derives it after this guard. if (grantSetNameOf(link) !== ADMIN_FULL_ACCESS) continue; - // An org-SCOPED grant makes a tenant admin, not the environment's - // break-glass admin — the same distinction `resolveAuthzContext` draws - // when it derives `platform_admin` from the unscoped grant only. This - // is also the "scope it to an org" revocation shape: a pending write - // that fills `organization_id` in lands here. - if (link.organization_id ?? link.organizationId) continue; + // Any other organization's grant makes a tenant admin, not the + // environment's break-glass admin — the same distinction + // `resolveAuthzContext` draws (`grantAnchorsPlatformStanding`): the + // anchor is the Default Organization's grant, or an organization-less + // one. This is also the "scope it to another org" revocation shape: a + // pending write that re-points `organization_id` lands here, and so + // does a pending organization write that stops naming the owner the + // Default Organization. + const owner = toId(link.organization_id ?? link.organizationId); + if (owner !== undefined && owner !== defaultOrganizationId) continue; // ADR-0091's ONE validity predicate, consumed exactly as it already was // — the guard invents no expiry semantics of its own (#5893 owns that // question, and is blocked on #5702). A pending write that moves @@ -1622,6 +1726,27 @@ export function registerLastAdminGuard( await enforceStanding('activation-insert', METADATA_ACTIVATION, ctx.input, undefined, data); }; + /** + * [ADR-0131 D5] The organization half: re-slugging or removing the Default + * Organization moves the anchor off the grant that makes its platform + * administrator one, so it is judged like every other standing write. A + * payload touching no standing key ({@link ORGANIZATION_STANDING_KEYS}) costs + * no reads. + */ + const guardOrganizationUpdate = async (rawCtx: unknown): Promise => { + const ctx = ctxOf(rawCtx); + if (ctx.object !== SystemObjectName.ORGANIZATION) return; + const data = (ctx.input?.data ?? {}) as Record; + if (!touchesAny(data, ORGANIZATION_STANDING_KEYS)) return; + await enforceStanding('organization-update', SystemObjectName.ORGANIZATION, ctx.input, data); + }; + + const guardOrganizationDelete = async (rawCtx: unknown): Promise => { + const ctx = ctxOf(rawCtx); + if (ctx.object !== SystemObjectName.ORGANIZATION) return; + await enforceStanding('organization-delete', SystemObjectName.ORGANIZATION, ctx.input); + }; + // Priority 20: AFTER the ADR-0092 identity write guard's checks (10), before // default-priority hooks (100) spend work on a write this may refuse. The // four standing hooks (#5978) and the two activation-ledger hooks (#6084) are @@ -1673,11 +1798,22 @@ export function registerLastAdminGuard( priority: 20, packageId, }); + engine.registerHook('beforeUpdate', guardOrganizationUpdate, { + object: SystemObjectName.ORGANIZATION, + priority: 20, + packageId, + }); + engine.registerHook('beforeDelete', guardOrganizationDelete, { + object: SystemObjectName.ORGANIZATION, + priority: 20, + packageId, + }); logger?.info( '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' + 'email/email_verified pair the deployment-config anchor derives from), sys_member and ' + - 'sys_user_permission_set (standing revocation), and sys_metadata_activation ' + + 'sys_user_permission_set (standing revocation), sys_organization (the Default ' + + 'Organization the grant anchor reads) and sys_metadata_activation ' + '(switching admin_full_access off) — ADR-0135 D5.2', ); } From edae1ddc3f0a4bada2e5fc89902f427e27780585 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:46:14 +0000 Subject: [PATCH 3/7] wip(security): test fixtures hold the Default Organization the single boot creates Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- ...latform-admin-authenticable-target.test.ts | 7 ++- ...latform-admin-existing-holder-scan.test.ts | 18 ++++--- ...p-platform-admin-fresh-boot.golden.test.ts | 53 +++++++++++++++++-- ...platform-admin-promotion-selection.test.ts | 9 +++- ...tstrap-platform-admin-walled-owner.test.ts | 3 ++ .../src/builtin-positions.boot.test.ts | 5 ++ ...m-seed-ownership-seed-settle-rerun.test.ts | 2 + .../grant-permission-set-name.writers.test.ts | 9 +++- 8 files changed, 89 insertions(+), 17 deletions(-) diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index 358de73ab5c..b7046c0ca9d 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -48,7 +48,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { SysUser, SysAccount, SysOrganization } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; @@ -92,7 +92,7 @@ async function boot(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount], + objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysOrganization], } as any); engine.registerApp({ id: 'com.example.seeded-app', @@ -110,6 +110,9 @@ async function boot(): Promise { ], } as any); await engine.syncSchemas(); + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any + // `kernel:ready` hook runs; it owns the promotion's grant. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); engines.push(engine); return engine; } diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index bb4b4dd463f..3f2a5a42558 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -73,7 +73,7 @@ import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { SysUser, SysAccount, SysOrganization } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, PLATFORM_ADMIN_GRANT_PAGE_SIZE, @@ -128,9 +128,12 @@ async function boot(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount], + objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysOrganization], } as any); await engine.syncSchemas(); + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any + // `kernel:ready` hook runs; it owns the promotion's grant. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); engines.push(engine); return engine; } @@ -469,9 +472,10 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); } if (object === 'sys_user_permission_set') { - // Every synthetic row is organization-scoped, so the narrow leg - // (`organization_id: null`) legitimately answers with nothing. - if ('organization_id' in where && where.organization_id === null) return []; + // Every synthetic row is another organization's, so the narrow leg + // (`organization_id: null`, and the Default Organization's) + // legitimately answers with nothing. + if ('organization_id' in where && (where.organization_id === null || where.organization_id === 'org_default')) return []; const offset = q?.offset ?? 0; const limit = q?.limit ?? 100; const out: any[] = []; @@ -493,6 +497,8 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = : []; } if (object === 'sys_account') return [{ id: 'acc_p', user_id: 'usr_promotable' }]; + // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; return []; }, async insert(object: string, data: any) { @@ -523,7 +529,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(said).toContain('stopped at its ceiling'); expect(said).toContain(String(PLATFORM_ADMIN_GRANT_SCAN_CEILING)); expect(said).toContain('were NOT examined'); - expect(said).toContain('SECOND unscoped grant'); + expect(said).toContain('SECOND platform-admin grant'); }); it('CONTROL: a population inside the ceiling produces no truncation warning', async () => { diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts index a49c03de00e..a3a7ce68c93 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-fresh-boot.golden.test.ts @@ -25,7 +25,7 @@ import { describe, it, expect, afterEach, beforeEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { SysUser, SysAccount, SysOrganization } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { securityObjects } from './manifest.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; @@ -53,7 +53,12 @@ afterEach(async () => { resetPlatformAdminEmailMemo(); }); -/** A fresh `:memory:` database holding the security plugin's own objects and the identity tables. */ +/** + * A fresh `:memory:` database holding the security plugin's own objects and the + * identity tables — and the Default Organization a `single` boot creates before + * any `kernel:ready` hook runs (ADR-0131 D3, plugin-auth's boot invariant), + * which owns the promotion's grant (D5). + */ async function freshEngine(): Promise { const engine = new ObjectQL(); engine.registerDriver( @@ -67,9 +72,10 @@ async function freshEngine(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysUser, SysAccount, ...securityObjects], + objects: [SysUser, SysAccount, SysOrganization, ...securityObjects], } as any); await engine.syncSchemas(); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS); engines.push(engine); return engine; } @@ -80,7 +86,7 @@ async function human(engine: ObjectQL, id: string, email: string, createdAt: str } describe('[ADR-0131 D3/D4] a fresh single-posture database promotes its first human', () => { - it('promotes the oldest account that can sign in, granting admin_full_access by name', async () => { + it('promotes the oldest account that can sign in, granting admin_full_access by name, owned by the Default Organization', async () => { const engine = await freshEngine(); await human(engine, 'usr_first', 'first@fresh.example', '2026-01-01T00:00:00.000Z'); await human(engine, 'usr_second', 'second@fresh.example', '2026-02-01T00:00:00.000Z'); @@ -89,8 +95,9 @@ describe('[ADR-0131 D3/D4] a fresh single-posture database promotes its first hu expect(report).toMatchObject({ adminPromoted: true, adminUserId: 'usr_first' }); const grants = (await engine.find('sys_user_permission_set', { where: {}, ...SYS })) as any[]; + // [ADR-0131 D5] The row is the Default Organization's — ⛔ never a NULL organization. expect(grants.map((g) => [g.user_id, g.permission_set, g.organization_id ?? null])).toEqual([ - ['usr_first', 'admin_full_access', null], + ['usr_first', 'admin_full_access', 'org_default'], ]); }); @@ -103,5 +110,41 @@ describe('[ADR-0131 D3/D4] a fresh single-posture database promotes its first hu const again = await bootstrapPlatformAdmin(engine, defaultPermissionSets); expect(again).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_first' }); + const grants = (await engine.find('sys_user_permission_set', { where: {}, ...SYS })) as any[]; + expect(grants).toHaveLength(1); + }); + + it('an upgraded database whose administrator\'s grant the ceremony gave the Default Organization promotes nobody else', async () => { + // ADR-0131 D10 attributes a `single` database's organization-less grants to + // the Default Organization. A holder scan reading only the NULL row found + // nobody on such a database and minted a second grant on every boot. + const engine = await freshEngine(); + await human(engine, 'usr_admin', 'admin@fresh.example', '2026-01-01T00:00:00.000Z'); + await human(engine, 'usr_older', 'older@fresh.example', '2025-01-01T00:00:00.000Z'); + await engine.insert('sys_user_permission_set', { + id: 'ups_attributed', user_id: 'usr_admin', permission_set: 'admin_full_access', organization_id: 'org_default', + }, SYS); + + const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets); + + expect(report).toMatchObject({ adminPromoted: false, reason: 'already_have_admin', adminUserId: 'usr_admin' }); + const grants = (await engine.find('sys_user_permission_set', { where: {}, ...SYS })) as any[]; + expect(grants.map((g) => g.id)).toEqual(['ups_attributed']); + }); + + it('⛔ a database that names no Default Organization is not promoted, says so at error, and writes no grant', async () => { + const engine = await freshEngine(); + await engine.insert('sys_organization', { id: 'org_second', name: 'Second', slug: 'second' }, SYS); + await engine.update('sys_organization', { slug: 'first' }, { where: { id: 'org_default' }, ...SYS } as any); + await human(engine, 'usr_first', 'first@fresh.example', '2026-01-01T00:00:00.000Z'); + const errors: string[] = []; + + const report = await bootstrapPlatformAdmin(engine, defaultPermissionSets, { + logger: { info: () => {}, warn: () => {}, error: (m: string) => errors.push(m) }, + }); + + expect(report).toMatchObject({ adminPromoted: false, reason: 'default_organization_unresolved' }); + expect(errors).toHaveLength(1); + expect(await engine.find('sys_user_permission_set', { where: {}, ...SYS })).toEqual([]); }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index c899905cd2a..8aa6ec10b77 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -64,7 +64,7 @@ import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { SysUser, SysAccount } from '@objectstack/platform-objects/identity'; +import { SysUser, SysAccount, SysOrganization } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE, @@ -125,9 +125,12 @@ async function boot(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount], + objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysOrganization], } as any); await engine.syncSchemas(); + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any + // `kernel:ready` hook runs; it owns the promotion's grant. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); engines.push(engine); return engine; } @@ -463,6 +466,8 @@ describe('#16682 — the promotion target is chosen, not sampled', () => { ['sys_user', users.map((r) => ({ ...r }))], ['sys_user_permission_set', []], ['sys_account', accounts.map((r) => ({ ...r }))], + // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. + ['sys_organization', [{ id: 'org_default', slug: 'default' }]], ]); return { grants: () => tables.get('sys_user_permission_set')!, diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts index 18e2a952443..7e8bad5acdf 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -65,6 +65,9 @@ function makeQl(seed: { users?: any[]; grants?: any[]; sets?: any[]; accounts?: ['sys_user', (seed.users ?? []).map((r) => ({ ...r }))], ['sys_user_permission_set', (seed.grants ?? []).map((r) => ({ ...r }))], ['sys_account', (seed.accounts ?? []).map((r) => ({ ...r }))], + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates, which + // owns the promotion's grant. + ['sys_organization', [{ id: 'org_default', slug: 'default' }]], ]); const rowsOf = (object: string) => tables.get(object) ?? []; return { diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 2230f166b76..4ec9e9c6937 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -322,6 +322,11 @@ async function boot(scenario: Scenario): Promise { }; if (scenario.organizationAtBoot) { await engine.insert('sys_organization', { id: ORG, name: 'Eq Org', slug: 'eq' }, { context: SYS } as any); + } else if (scenario.posture === 'single') { + // [ADR-0131 D3/D5] A `single` boot always holds its Default Organization — + // plugin-auth's boot invariant creates it before `kernel:ready` — and it owns + // the first platform administrator's grant. No membership comes with it. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: SYS } as any); } await user('usr_admin', 'admin@eq.example', '2025-01-01T00:00:00.000Z'); await user('usr_orgadmin', 'orgadmin@eq.example', '2025-02-01T00:00:00.000Z'); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts index f1f4fb69542..5e5035ce74c 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -199,6 +199,8 @@ async function bootPlugin(existing?: Record) { sys_account: [{ id: 'acc_1', user_id: ADMIN, provider_id: 'credential' }], sys_user_permission_set: [], sys_permission_set: [], + // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. + sys_organization: [{ id: 'org_default', slug: 'default' }], // WINNER: rows the seeder already wrote before the promotion instant. crm_account: [seedRow('acc_seed_1'), seedRow('acc_seed_2')], // LOSER: the object whose rows the background seed has not reached yet. diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts index 3c01a5db8f1..e48f40fef79 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.writers.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { SysUser, SysAccount, SysMember } from '@objectstack/platform-objects/identity'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; import { bootstrapPlatformAdmin, findExistingPlatformAdmin } from './bootstrap-platform-admin.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; @@ -53,9 +53,12 @@ async function boot(): Promise { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysMember], + objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysMember, SysOrganization], } as any); await engine.syncSchemas(); + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any + // `kernel:ready` hook runs; it owns the promotion's grant. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); engines.push(engine); return engine; } @@ -92,6 +95,8 @@ describe('[ADR-0131 D4] the grant writers in plugin-security write the set by na const grants = await grantsOf(engine, 'usr_first'); expect(grants).toHaveLength(1); expectNamedOnly(grants[0], 'admin_full_access'); + // [ADR-0131 D5] …owned by the Default Organization, never NULL. + expect(grants[0]?.organization_id).toBe('org_default'); // [C3 stage 6b-2] The bootstrap wrote no catalog row, and the holder is // read by name with none. From fed07db24c75c8dd6c7ec1b4c6cb04bcd78bbbd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:57:19 +0000 Subject: [PATCH 4/7] wip(auth): the guard's evidence reads and the default-org bootstrap follow the Default Organization's grant Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- .../src/api-key-restore-guard.test.ts | 3 + .../src/ensure-default-organization.test.ts | 34 ++++ .../src/ensure-default-organization.ts | 24 ++- .../src/grant-readers-by-name.golden.test.ts | 13 +- .../src/grant-readers-unnamed-grant.test.ts | 3 + ...human-user-predicate-agreement.pin.test.ts | 2 + ...t-admin-guard.default-organization.test.ts | 174 ++++++++++++++++++ .../plugin-auth/src/last-admin-guard.ts | 58 ++++-- .../src/last-admin-standing-keys.test.ts | 3 + 9 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts diff --git a/packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts b/packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts index 235c2d850f5..cf99181e4d3 100644 --- a/packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts +++ b/packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts @@ -98,6 +98,9 @@ async function rig(): Promise { ], } as any); await engine.syncSchemas(); + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before + // `kernel:ready` (AuthPlugin's invariant); it owns the promotion's grant. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS); await security.start(ctx); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 252389b2c49..2bdcd92e581 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -154,6 +154,40 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => { expect(res.memberCreated).toBe(true); }); + it('[ADR-0131 D5] binds the holder of the Default Organization\'s admin grant — the row the single promotion writes', async () => { + const ql = makeQl({ + sys_organization: [{ id: 'org_default', slug: 'default', name: 'Default Organization' }], + sys_user_permission_set: [ + { id: 'ups_d', user_id: 'u_promoted', permission_set: 'admin_full_access', organization_id: 'org_default' }, + ], + }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, defaultOrgId: 'org_default', memberCreated: true }); + expect(ql.tables.sys_member).toEqual([expect.objectContaining({ user_id: 'u_promoted', role: 'owner', organization_id: 'org_default' })]); + }); + + it('[ADR-0131 D5] ⛔ another organization\'s admin grant is an organization administrator\'s, never the anchor', async () => { + const ql = makeQl({ + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_b', slug: 'b' }], + sys_user_permission_set: [ + { id: 'ups_b', user_id: 'u_org_admin', permission_set: 'admin_full_access', organization_id: 'org_b' }, + ], + }); + expect(await ensureDefaultOrganization(ql)).toMatchObject({ memberCreated: false, reason: 'no_admin' }); + }); + + it('[ADR-0131 D3/D5] a sole organization with another slug IS the Default Organization: the admin is bound there, no second one is minted', async () => { + const ql = makeQl({ + sys_organization: [{ id: 'org_acme', slug: 'acme', name: 'Acme' }], + sys_user_permission_set: [ + { id: 'ups_a', user_id: 'u1', permission_set: 'admin_full_access', organization_id: 'org_acme' }, + ], + }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, defaultOrgId: 'org_acme', memberCreated: true }); + expect(ql.tables.sys_organization).toHaveLength(1); + }); + it('picks the OLDEST cross-tenant admin grant', async () => { const ql = makeQl({ sys_user_permission_set: [ diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 9bf10531f6d..f4b3165b4af 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -66,7 +66,8 @@ * the reconciler's own `member` row on the Default Organization, which is * promoted to `owner` while the bind is undecided (ADR-0131 D3: that * organization now exists before the admin does). - * 3. Re-use a pre-existing `slug='default'` org if present; otherwise + * 3. Re-use the Default Organization if present — the `slug='default'` + * org, else the sole org (the boot invariant's definition); otherwise * create one. Stable slug keeps human-readable URLs predictable across * cold-boots. * 4. Insert a `sys_member { role: 'owner' }` linking the admin to the @@ -86,7 +87,6 @@ import { postureEnforcesWall } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; import { permissionSetInEffect } from './catalog-set-in-effect.js'; -import { resolveDefaultOrgId } from './tenancy-service.js'; interface BootstrapLogger { info: (message: string, meta?: Record) => void; @@ -500,7 +500,7 @@ export async function ensureDefaultOrganization( // (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`). Reading only // the organization-less row would answer `no_admin` on every deployment // promoted since, and never bind its administrator. - const defaultOrgId = await resolveDefaultOrgId(ql); + const defaultOrgId = await findDefaultOrganizationId(ql); const adminGrants: any[] = []; for (const organizationId of defaultOrgId ? [null, defaultOrgId] : [null]) { adminGrants.push(...await tryFind( @@ -543,11 +543,23 @@ export async function ensureDefaultOrganization( } // 4. Re-use or create the `default` org. + // + // [ADR-0131 D3/D5] Under `single`, "re-use" is the Default Organization as + // the boot invariant names it — `slug='default'`, else the sole + // organization — so a deployment whose only organization carries another + // slug binds its admin there rather than minting a second, + // `default`-slugged one. A second one would BECOME the Default Organization + // and move the platform-admin anchor off the grant the promotion wrote + // for the first. Under a wall an organization is a tenant's boundary, never + // "the" organization, so only the `default`-slugged one is re-used. let defaultOrgId: string | undefined; let defaultOrgCreated = false; - const existingDefault = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1); - if (existingDefault.length > 0 && existingDefault[0].id) { - defaultOrgId = String(existingDefault[0].id); + const existingDefaultId = legacyGrantAnchorRetired + ? (await tryFind(ql, 'sys_organization', { slug: 'default' }, 1)) + .map((row) => (row?.id ? String(row.id) : undefined))[0] + : await findDefaultOrganizationId(ql); + if (existingDefaultId) { + defaultOrgId = existingDefaultId; if (options.bindOnlyOnCreate) { // The organization predates this call, so whether its owner was bound // was decided earlier — never re-decided here (ADR-0093 D7). diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index ec3d3c36520..d14080f2608 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -207,8 +207,10 @@ async function standingByReader(posture: Posture): Promise engine.update( 'sys_member', { role: 'member' }, { where: { role: 'owner' }, multi: true, ...sys }, )); + // [ADR-0131 D5] Under `single` the promotion's grant is the Default + // Organization's (here the sole organization, `ORG`) — never organization-less. const [adminGrant] = (await engine.find('sys_user_permission_set', { - where: { user_id: 'usr_admin', organization_id: null }, context: SYS, + where: { user_id: 'usr_admin', permission_set: 'admin_full_access', organization_id: walled ? null : ORG }, context: SYS, })) as any[]; guard.revokeSoleAdministratorGrant = adminGrant ? await verdict(() => engine.delete('sys_user_permission_set', { where: { id: adminGrant.id }, ...sys })) @@ -249,8 +251,15 @@ describe('[ADR-0131 D4] grant readers by name (plugin-auth) — platform standin * refused; under a wall the declared owner carries no grant row and outlives * the set's deactivation, so only banning the account itself is refused. */ +// ⚠️ One recorded move ([ADR-0131] D3/D5): under `single` the fixture's sole +// organization (`ORG`, slug `gra`) IS the Default Organization — the boot +// invariant's definition, which owns the promotion's grant — so the +// default-organization bootstrap binds the platform administrator there beside +// its existing owner, where it used to mint a second, `default`-slugged +// organization that would have taken the anchor off that grant. Every guard +// verdict is unchanged. const SINGLE: unknown = { - defaultOrganization: { memberCreated: true, reason: null, owners: ['usr_admin'] }, + defaultOrganization: { memberCreated: true, reason: null, owners: ['usr_admin', 'usr_orgadmin'] }, lastAdminGuard: { banMember: 'permitted', banEveryAdministrator: 'refused:PERMISSION_DENIED', diff --git a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts index 45ca5f60f00..e2384898862 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts @@ -113,6 +113,9 @@ async function soleGrantAnchoredAdmin(): Promise { ]), } as any); await engine.syncSchemas(); + // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before + // `kernel:ready` (AuthPlugin's invariant); it owns the promotion's grant. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS); await plugin.start(ctx); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); registerGuard(engine); diff --git a/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts b/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts index 05db6e6a19f..3fd4e33c32e 100644 --- a/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts +++ b/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts @@ -114,6 +114,8 @@ function makeQl(userRows: unknown[]) { sys_permission_set: [], sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[], sys_user_permission_set: [], + // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. + sys_organization: [{ id: 'org_default', slug: 'default' }], // [#14348] Every probed row that CAN hold an account gets one. // // This probe reads plugin-security's human verdict indirectly, as diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts new file mode 100644 index 00000000000..fb993174e57 --- /dev/null +++ b/packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D5] Under `single` the first-user promotion's `admin_full_access` + * grant is OWNED BY THE DEFAULT ORGANIZATION, and `resolveAuthzContext` reads + * that row as the anchor of platform standing (`grantAnchorsPlatformStanding`). + * The break-glass guard answers the same question, so two things hold here: + * + * 1. **The enumeration counts that grant's holder.** An enumeration reading + * only the organization-less row would see an environment with no + * administrator and wave through the ban, the revocation or the re-scoping + * that ends the last one. + * 2. **The guard judges the organization writes that move the anchor.** The + * Default Organization is the one with `slug = 'default'`, else the only + * one — so re-slugging it while another organization exists, or deleting + * it, takes the standing away with no grant row touched. + * + * Same method as `last-admin-guard.config-anchor.test.ts`: a REAL ObjectQL + * engine over better-sqlite3 `:memory:`, so the engine dispatches the hooks. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; + +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const MULTI_ORG_ENV = 'OS_MULTI_ORG_ENABLED'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const SYSTEM = { context: { isSystem: true } } as const; + +const text = (name: string) => ({ name, type: 'text' as const }); +const OBJECTS = [ + { + name: 'sys_user', + label: 'User', + managedBy: 'better-auth', + fields: { + id: { ...text('id'), primaryKey: true }, + name: text('name'), + email: text('email'), + email_verified: { name: 'email_verified', type: 'boolean' as const }, + banned: { name: 'banned', type: 'boolean' as const, readonly: true }, + }, + }, + { + name: 'sys_member', + label: 'Member', + managedBy: 'better-auth', + fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), organization_id: text('organization_id'), role: text('role') }, + }, + { + name: 'sys_organization', + label: 'Organization', + managedBy: 'better-auth', + fields: { id: { ...text('id'), primaryKey: true }, name: text('name'), slug: text('slug') }, + }, + { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { ...text('id'), primaryKey: true }, + user_id: text('user_id'), + permission_set: text('permission_set'), + organization_id: text('organization_id'), + valid_from: { name: 'valid_from', type: 'datetime' as const }, + valid_until: { name: 'valid_until', type: 'datetime' as const }, + }, + }, +]; + +let engines: ObjectQL[] = []; +const ambient: Record = {}; + +beforeEach(() => { + for (const name of [POSTURE_ENV, MULTI_ORG_ENV, OWNER_ENV]) { + ambient[name] = process.env[name]; + delete process.env[name]; + } + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + for (const [name, value] of Object.entries(ambient)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + resetPlatformAdminEmailMemo(); + const open = engines; + engines = []; + for (const e of open) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +/** + * A `single` environment whose sole administrator holds the grant the promotion + * writes — owned by the Default Organization — and nothing else (no + * membership, so the organization-owner half of the enumeration counts nobody). + */ +async function promotedSingle(opts: { secondOrganization?: boolean; grantOwner?: string } = {}): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const o of OBJECTS) engine.registry.registerObject(o as never); + await engine.syncSchemas(); + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard.default-org' }); + + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYSTEM); + if (opts.secondOrganization) { + await engine.insert('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }, SYSTEM); + } + for (const id of ['usr_admin', 'usr_other']) { + await engine.insert('sys_user', { id, name: id, email: `${id}@d.example`, email_verified: true, banned: false }, SYSTEM); + } + await engine.insert('sys_user_permission_set', { + id: 'ups_admin', user_id: 'usr_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: opts.grantOwner ?? 'org_default', + }, SYSTEM); + return engine; +} + +const ban = (engine: ObjectQL, id: string) => engine.update('sys_user', { id, banned: true }, SYSTEM); +const refusal = { code: 'PERMISSION_DENIED', status: 403 }; + +describe('[ADR-0131 D5] the enumeration counts the holder of the Default Organization\'s grant', () => { + it('banning that sole administrator is refused', async () => { + const engine = await promotedSingle(); + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); + }); + + it('revoking the grant, or re-scoping it to another organization, is refused', async () => { + const engine = await promotedSingle({ secondOrganization: true }); + await expect(engine.delete('sys_user_permission_set', { where: { id: 'ups_admin' }, ...SYSTEM } as any)) + .rejects.toMatchObject(refusal); + await expect(engine.update('sys_user_permission_set', { id: 'ups_admin', organization_id: 'org_b' }, SYSTEM)) + .rejects.toMatchObject(refusal); + }); + + it('CONTROL — another organization\'s grant of the set makes no administrator: banning its holder is permitted', async () => { + const engine = await promotedSingle({ secondOrganization: true, grantOwner: 'org_b' }); + await expect(ban(engine, 'usr_admin')).resolves.toBeDefined(); + }); +}); + +describe('[ADR-0131 D5] the organization writes that move the anchor off that grant are judged', () => { + it('re-slugging the Default Organization while another organization exists is refused', async () => { + const engine = await promotedSingle({ secondOrganization: true }); + await expect(engine.update('sys_organization', { id: 'org_default', slug: 'renamed' }, SYSTEM)) + .rejects.toMatchObject(refusal); + }); + + it('deleting the Default Organization is refused', async () => { + const engine = await promotedSingle(); + await expect(engine.delete('sys_organization', { where: { id: 'org_default' }, ...SYSTEM } as any)) + .rejects.toMatchObject(refusal); + }); + + it('CONTROL — a sole organization stays the Default Organization whatever its slug, and a name change is no standing write', async () => { + const engine = await promotedSingle(); + await expect(engine.update('sys_organization', { id: 'org_default', slug: 'acme' }, SYSTEM)).resolves.toBeDefined(); + await expect(engine.update('sys_organization', { id: 'org_default', name: 'Acme Inc' }, SYSTEM)).resolves.toBeDefined(); + // …and the administrator is still counted afterwards. + await expect(ban(engine, 'usr_admin')).rejects.toMatchObject(refusal); + }); +}); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index a3dba8cb842..3398a83292e 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -1002,6 +1002,31 @@ export function registerLastAdminGuard( return remaining.length === 1 ? toId(remaining[0]!.id) : undefined; }; + /** + * [ADR-0131 D5] Which `admin_full_access` grants among `links` the anchor + * reads — the resolver's predicate (`grantAnchorsPlatformStanding`, + * `resolve-authz-context.ts`): one owned by no organization, or, under + * `single`, by the Default Organization ({@link readDefaultOrganizationId}, + * read only when some grant names an organization). Every other + * organization's grant is an organization administrator's. Under a wall only + * the organization-less row is asked about, and only as evidence — it + * anchors nothing there. + */ + const anchorOwnerTest = async ( + op: GuardedOp, + links: Array>, + pending?: PendingStandingWrite, + ): Promise<(link: Record) => boolean> => { + const ownerOf = (link: Record) => toId(link.organization_id ?? link.organizationId); + const defaultOrganizationId = !postureEnforcesWall(resolveTenancyPosture()) && links.some((l) => ownerOf(l)) + ? await readDefaultOrganizationId(op, pending) + : undefined; + return (link) => { + const owner = ownerOf(link); + return owner === undefined || owner === defaultOrganizationId; + }; + }; + /** * Every user this environment recognises as an administrator. * @@ -1066,11 +1091,13 @@ export function registerLastAdminGuard( const links = await scan(op, USER_PERMISSION_SET, { where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS }, }); - // [ADR-0131 D5] Asked only when some grant names an organization: a - // deployment whose grants are all organization-less reads nothing more. - const defaultOrganizationId = links.some((l) => toId(l.organization_id ?? l.organizationId)) - ? await readDefaultOrganizationId(op, pending) - : undefined; + // [ADR-0131 D5] Judged on the rows as this write would leave them, so a + // pending write that re-points a grant's owner is read where it lands. + const anchors = await anchorOwnerTest( + op, + links.map((raw) => applyPending(raw, pending, USER_PERMISSION_SET) ?? raw), + pending, + ); for (const raw of links) { const link = applyPending(raw, pending, USER_PERMISSION_SET); // Revoked outright by the pending write. @@ -1089,8 +1116,7 @@ export function registerLastAdminGuard( // pending write that re-points `organization_id` lands here, and so // does a pending organization write that stops naming the owner the // Default Organization. - const owner = toId(link.organization_id ?? link.organizationId); - if (owner !== undefined && owner !== defaultOrganizationId) continue; + if (!anchors(link)) continue; // ADR-0091's ONE validity predicate, consumed exactly as it already was // — the guard invents no expiry semantics of its own (#5893 owns that // question, and is blocked on #5702). A pending write that moves @@ -1262,9 +1288,10 @@ export function registerLastAdminGuard( where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS }, }); const nowMs = Date.now(); - const stranded = held.filter( - (link) => !(link.organization_id ?? link.organizationId) && isGrantActive(link, nowMs), - ); + // [ADR-0131 D5] The grants the anchor reads — the Default Organization's + // under `single`, beside the organization-less row. + const anchors = await anchorOwnerTest(op, held); + const stranded = held.filter((link) => anchors(link) && isGrantActive(link, nowMs)); if (stranded.length > 0) { const words = OP_WORDS[op]; logger?.warn( @@ -1295,6 +1322,7 @@ export function registerLastAdminGuard( const now = Date.now(); const dangling: string[] = []; + const anchors = await anchorOwnerTest(op, candidates); for (const link of candidates) { const name = grantSetNameOf(link); // `$nin` is NULL-safe on this engine (#5298) and the drivers differ on @@ -1302,10 +1330,12 @@ export function registerLastAdminGuard( // from the `where` — the same discipline the enumeration applies to // the grant's name and the set's `name`. if (name !== undefined && known.has(name)) continue; - // An org-SCOPED grant never conferred the environment's break-glass - // standing, and an out-of-window one never conferred it either, so - // neither is evidence that this environment once had a platform admin. - if (link.organization_id ?? link.organizationId) continue; + // Another organization's grant never conferred the environment's + // break-glass standing (ADR-0131 D5: the anchor is the Default + // Organization's grant, or an organization-less one), and an + // out-of-window one never conferred it either, so neither is evidence + // that this environment once had a platform admin. + if (!anchors(link)) continue; if (!isGrantActive(link, now)) continue; const uid = toId(link.user_id ?? link.userId); const target = name !== undefined ? `'${name}'` : '(no permission-set name yet)'; diff --git a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts index 92f389759c4..dbd7d692789 100644 --- a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts @@ -52,6 +52,7 @@ import { GRANT_STANDING_KEYS, MEMBER_STANDING_KEYS, ACTIVATION_LEDGER_STANDING_KEYS, + ORGANIZATION_STANDING_KEYS, STANDING_KEYS_BY_TABLE, STANDING_KEY_EXCLUSIONS, } from './last-admin-guard.js'; @@ -130,5 +131,7 @@ describe('[#8734] standing-key lists correspond to what resolveAuthzContext read // [ADR-0131 D3] The resolver reads deactivation from the activation ledger, // no longer from the `sys_permission_set` row, which the guard no longer judges. expect(STANDING_KEYS_BY_TABLE.sys_metadata_activation).toBe(ACTIVATION_LEDGER_STANDING_KEYS); + // [ADR-0131 D5] The organization whose grant anchors standing under `single`. + expect(STANDING_KEYS_BY_TABLE.sys_organization).toBe(ORGANIZATION_STANDING_KEYS); }); }); From 11016ab90763d0541d9861cbd0012272863ae2cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:11:13 +0000 Subject: [PATCH 5/7] fix(security): the single-posture platform-admin grant is owned by the Default Organization, and every reader of platform standing reads it Under the single tenancy posture the first-user promotion writes its admin_full_access grant owned by the Default Organization (ADR-0131 D5), never NULL. The authorization resolver, the holder scan, the default-organization bootstrap and the last-administrator guard read that row beside the organization-less one, so a database the ownership ceremony migrated keeps its administrator and is not promoted again. Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- .changeset/11979-default-org-admin-grant.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/11979-default-org-admin-grant.md diff --git a/.changeset/11979-default-org-admin-grant.md b/.changeset/11979-default-org-admin-grant.md new file mode 100644 index 00000000000..9bd1890679e --- /dev/null +++ b/.changeset/11979-default-org-admin-grant.md @@ -0,0 +1,14 @@ +--- +"@objectstack/core": patch +"@objectstack/plugin-security": patch +"@objectstack/plugin-auth": patch +--- + +fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row + +Clause-②: no + +- **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`. +- **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`. +- **An upgraded database keeps its administrator.** `os migrate organization-ownership --apply` gives a `single` database's organization-less grants to the Default Organization, the platform admin's grant included. Before this change the resolver then stopped grading that admin `PLATFORM_ADMIN`, and every later boot found no organization-less holder and promoted again. The holder scan now reads the Default Organization's grant, so a migrated database keeps its admin and promotes nobody else. A user who holds both rows counts as one holder. +- **The last-administrator guard and the default-organization bootstrap read it too (`@objectstack/plugin-auth`).** The break-glass guard counts the holder of the Default Organization's grant as a platform admin and treats that grant as evidence that the environment once had one. It also judges the two organization writes that move the anchor off that grant: changing the Default Organization's `slug` while another organization exists, and deleting it. Either is refused when it would leave no administrator who can sign in. `ensureDefaultOrganization` finds the admin through the same grant. Under `single` it also reuses the Default Organization when that is a sole organization with another slug, where it used to create a second, `default`-slugged organization. From 16c43fbda79373159dedb62ad56835b7b6a030ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:43:57 +0000 Subject: [PATCH 6/7] refactor(auth): the organization standing-key list stays module-private; the guard reads the map's entry Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- packages/plugins/plugin-auth/src/last-admin-guard.ts | 6 ++++-- .../plugin-auth/src/last-admin-standing-keys.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 3398a83292e..fdf2e3d1468 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -751,8 +751,10 @@ export const ACTIVATION_LEDGER_STANDING_KEYS = ['metadata_type', 'name', 'active * platform standing under `single` (`slug = 'default'`, else the only * organization). Re-slugging it while several organizations exist moves the * anchor off its grant; a delete is judged on every row, like the other tables'. + * Module-private: the guard tests payloads against the map's entry + * ({@link STANDING_KEYS_BY_TABLE}), so the list the gate reads is the list used. */ -export const ORGANIZATION_STANDING_KEYS = ['slug'] as const; +const ORGANIZATION_STANDING_KEYS = ['slug'] as const; /** * [commit f8eb73601] The three lists above, keyed by the table each one judges — the shape @@ -1767,7 +1769,7 @@ export function registerLastAdminGuard( const ctx = ctxOf(rawCtx); if (ctx.object !== SystemObjectName.ORGANIZATION) return; const data = (ctx.input?.data ?? {}) as Record; - if (!touchesAny(data, ORGANIZATION_STANDING_KEYS)) return; + if (!touchesAny(data, STANDING_KEYS_BY_TABLE[SystemObjectName.ORGANIZATION]!)) return; await enforceStanding('organization-update', SystemObjectName.ORGANIZATION, ctx.input, data); }; diff --git a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts index dbd7d692789..d868740d1fe 100644 --- a/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-standing-keys.test.ts @@ -52,7 +52,6 @@ import { GRANT_STANDING_KEYS, MEMBER_STANDING_KEYS, ACTIVATION_LEDGER_STANDING_KEYS, - ORGANIZATION_STANDING_KEYS, STANDING_KEYS_BY_TABLE, STANDING_KEY_EXCLUSIONS, } from './last-admin-guard.js'; @@ -132,6 +131,7 @@ describe('[#8734] standing-key lists correspond to what resolveAuthzContext read // no longer from the `sys_permission_set` row, which the guard no longer judges. expect(STANDING_KEYS_BY_TABLE.sys_metadata_activation).toBe(ACTIVATION_LEDGER_STANDING_KEYS); // [ADR-0131 D5] The organization whose grant anchors standing under `single`. - expect(STANDING_KEYS_BY_TABLE.sys_organization).toBe(ORGANIZATION_STANDING_KEYS); + // The guard reads this entry of the map itself, so only its content is pinned. + expect(STANDING_KEYS_BY_TABLE.sys_organization).toEqual(['slug']); }); }); From 2af8fd548e43ae089975dfdc45e7e74507e290ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:49:10 +0000 Subject: [PATCH 7/7] =?UTF-8?q?docs(changeset):=20declare=20the=20single-p?= =?UTF-8?q?osture=20grant=20move=20as=20Clause-=E2=91=A1=20yes=20(narrowin?= =?UTF-8?q?g),=20minor,=20with=20its=20FROM=20=E2=86=92=20TO=20pairs=20and?= =?UTF-8?q?=20ADR-0087=20disposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01A3vH4wwSVnJvvx5BcfUpPf Co-authored-by: Claude --- .changeset/11979-default-org-admin-grant.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.changeset/11979-default-org-admin-grant.md b/.changeset/11979-default-org-admin-grant.md index 9bd1890679e..47d85689a9b 100644 --- a/.changeset/11979-default-org-admin-grant.md +++ b/.changeset/11979-default-org-admin-grant.md @@ -1,12 +1,21 @@ --- -"@objectstack/core": patch -"@objectstack/plugin-security": patch -"@objectstack/plugin-auth": patch +"@objectstack/core": minor +"@objectstack/plugin-security": minor +"@objectstack/plugin-auth": minor --- fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row -Clause-②: no +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- **FROM** (`@objectstack/plugin-security`): a `single` deployment holding several organizations, none slugged `default`, promoted its first user with an organization-less `admin_full_access` grant. **TO:** it is not promoted; the bootstrap logs one `error` line, returns `reason: 'default_organization_unresolved'` and writes no grant row. **Fix:** give the intended organization the slug `default` (or keep one organization, or declare a walled posture); the next `kernel:ready` or sign-up pass promotes. +- **FROM** (`@objectstack/plugin-auth`): a `sys_organization` `slug` change or delete was never refused. **TO:** it is refused with `403 PERMISSION_DENIED` when that organization is the Default Organization whose `admin_full_access` grant is the last unbanned administrator's anchor. **Fix:** give another account administrator standing first, then make the change. +- **FROM** (`@objectstack/plugin-auth`): `ensureDefaultOrganization` under `single`, with a sole organization of another slug, created a second, `default`-slugged organization and bound the administrator there. **TO:** it reuses the sole organization; no second organization is created. **Fix:** none needed; an install that wants a `default`-slugged organization re-slugs its existing one or creates it explicitly. +- **Widening** (`@objectstack/core`): under `single` only an organization-less `admin_full_access` grant conferred `PLATFORM_ADMIN`. Now a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped `admin_full_access` grant gains the rung on upgrade; and `sys_organization` becomes a standing-deriving table (the resolver reads it only under `single` for a holder of an organization-owned `admin_full_access` grant — one or two small reads per uncached request; the last-administrator guard adds three to six small reads per guarded write). - **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`. - **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`.