From eeec9df8a0826b1112fc9966c6671439822f320b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:13:13 +0000 Subject: [PATCH 1/7] fix(plugin-auth)!: the last-admin guard and the default-org bootstrap read only the Default Organization's admin_full_access grant An organization-less grant anchors nothing since 18.x step 7a-2 (ADR-0131 D1/D5); the guard counted one, so it failed open where such a row exists. Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- .../src/ensure-default-organization.ts | 29 +++--- .../plugin-auth/src/last-admin-guard.ts | 96 +++++++++---------- 2 files changed, 59 insertions(+), 66 deletions(-) diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index f4b3165b4af..345d358171d 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -36,8 +36,9 @@ * audit surface reports as holding standing. * 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 + * owned by the Default Organization, and none when no Default + * Organization is nameable (an organization-less grant anchors nothing, + * ADR-0131 D1). 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). * @@ -446,7 +447,7 @@ export async function ensureDefaultOrganization( let adminUserId: string | undefined = await findConfigAnchoredAdminUserId(ql); // 2. GRANT anchor (oldest `admin_full_access` grant owned by the Default - // Organization or by none) — what anchors `single` posture (Choice 4A; + // Organization) — 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]. // @@ -494,22 +495,20 @@ export async function ensureDefaultOrganization( // trigger binds once the grant names its set. // // [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. + // `single`'s first-user promotion writes and the one the derivation site + // reads as the anchor (`grantAnchorsPlatformStanding`, + // `resolve-authz-context.ts`). An organization-less grant anchors nothing + // there (ADR-0131 D1), so it binds nobody here: with no nameable Default + // Organization no grant is read and the answer is `no_admin`. const defaultOrgId = await findDefaultOrganizationId(ql); - const adminGrants: any[] = []; - for (const organizationId of defaultOrgId ? [null, defaultOrgId] : [null]) { - adminGrants.push(...await tryFind( + const adminGrants: any[] = defaultOrgId + ? await tryFind( ql, 'sys_user_permission_set', - { [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: organizationId }, + { [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: defaultOrgId }, 50, - )); - } + ) + : []; if (adminGrants.length === 0) { return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index a1ac4429611..09d7c2b9510 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -131,8 +131,11 @@ * 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 + * writes), **under a non-walled posture only**. A grant owned by no + * organization anchors nothing (ADR-0131 D1), and a composition that + * registers no organization object has no Default Organization, so no + * grant anchors there: its administrators are the two grades below. 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 @@ -194,13 +197,14 @@ * * - **a genuinely fresh environment** — no evidence anybody was ever a platform * admin here. Permitted, exactly as before. - * - **an environment that was EMPTIED** — an unscoped, in-window - * `sys_user_permission_set` grant still names a permission set the security + * - **an environment that was EMPTIED** — an in-window + * `sys_user_permission_set` grant the anchor reads (the Default + * Organization's) still names a permission set the security * catalog no longer holds (ADR-0131 D4). Refused, loudly, naming the * dangling grants. * - **an environment whose break-glass set was switched OFF** (ADR-0049, - * ADR-0131 D3) — the `admin_full_access` definition is there, and unscoped, - * in-window grants still name it, but the activation ledger says it is off + * ADR-0131 D3) — the `admin_full_access` definition is there, and the + * Default Organization's in-window grants still name it, but the activation ledger says it is off * so it confers nothing. Refused too, with its own remedy: switch it back * on. It produces the identical emptiness while leaving no dangling grant * to read. A write that RESTORES standing — switching it back on — is @@ -218,9 +222,9 @@ * instead (`guardPermissionSetUpdate`), which narrows the residual to one state * — an environment renamed away from `admin_full_access` by a write that landed * while this guard was not registered. Widening the predicate to cover it ("no - * `admin_full_access` row exists AND some unscoped grant does") was considered + * `admin_full_access` row exists AND some anchoring grant does") was considered * and rejected: it changes the answer for a fresh environment that writes an - * unscoped grant before the admin set exists, and not changing the fresh-install + * anchoring grant before the admin set exists, and not changing the fresh-install * answer is the one thing this predicate may not do. * * ## Relationship to the `auth-manager.ts` break-glass HTTP guard @@ -319,7 +323,7 @@ import { } from '@objectstack/spec/identity'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { SystemObjectName, SystemUserId } from '@objectstack/spec/system'; -import { PLATFORM_OWNER_EMAIL_ENV, isMissingTableError, resolveTenancyPosture } from '@objectstack/types'; +import { isMissingTableError, resolveTenancyPosture } from '@objectstack/types'; import { isGrantActive, matchesConfiguredPlatformAdmin, @@ -713,7 +717,7 @@ export const GRANT_STANDING_KEYS = [ * that does not revoke anything stored. * * The platform-admin re-anchor gave `resolveAuthzContext` a second anchor - * beside the unscoped `admin_full_access` grant: a `sys_user` row whose + * beside the Default Organization's `admin_full_access` grant: a `sys_user` row whose * `email` is on the deployment's declared administrator list AND whose * `email_verified` reads verified. Both columns are therefore derivation * columns now (`ADMIN_STANDING_SURFACE.sys_user`, reclassified from @@ -867,7 +871,7 @@ export const STANDING_KEY_EXCLUSIONS: Readonly { const owner = ownerOf(link); - return owner === undefined || owner === defaultOrganizationId; + return owner !== undefined && owner === defaultOrganizationId; }; }; @@ -1077,8 +1082,7 @@ export function registerLastAdminGuard( const now = Date.now(); // 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. + // Default Organization (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( @@ -1145,9 +1149,10 @@ export function registerLastAdminGuard( // 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 + // anchor is the Default Organization's grant, and an + // organization-less one is nobody's. 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. if (!anchors(link)) continue; @@ -1268,8 +1273,9 @@ export function registerLastAdminGuard( * decide whether that emptiness is the bootstrap window or an environment * that has just been emptied, and refuse in the second case. * - * The evidence is a DANGLING platform-admin-shaped grant: an unscoped, - * in-window `sys_user_permission_set` row naming a permission set the + * The evidence is a DANGLING platform-admin-shaped grant: an in-window + * `sys_user_permission_set` row the anchor reads (the Default + * Organization's, {@link anchorOwnerTest}) naming a permission set the * security catalog does not hold, or naming none (ADR-0131 D4: a grant holds * its set BY NAME, and the set is its catalog definition). On a fresh * install every grant names a set the platform ships, so the fresh answer is @@ -1278,7 +1284,7 @@ export function registerLastAdminGuard( * legacy grant the v18 ceremony's rewrite has not named (ADR-0131 D10). * * Deliberately an OVER-approximation of "the `admin_full_access` definition - * went away": a dangling unscoped grant to some other set trips it too. The + * went away": a dangling anchoring grant to some other set trips it too. The * guard cannot tell the two apart (the name it would compare went away with * the definition), and refusing in an environment that has zero * administrators AND a grant pointing into nowhere is the fail-closed @@ -1291,26 +1297,14 @@ export function registerLastAdminGuard( * amplifier the paragraph above exists to prevent — one switch-off empties * the administrator population and every later ban, delete and downgrade * sails through unguarded. So a switched-off `admin_full_access` with - * unscoped, in-window grants still naming it is read as the SAME evidence, + * anchoring, in-window grants still naming it is read as the SAME evidence, * with its own remedy: switch it back on. + * + * Under a WALLED posture no grant anchors, so neither shape is evidence + * there: standing is configuration only (`OS_PLATFORM_OWNER_EMAIL`, ADR-0131 + * D5), and restoring a grant row could not end the emptiness anyway. */ const refuseIfEmptiedRatherThanFresh = async (op: GuardedOp): Promise => { - // [#11663 L5] Both refusals below name ONE remedy — put the - // `admin_full_access` row back — and on a WALLED rig that remedy stopped - // working when the walled half of the legacy anchor retired: restoring or - // re-activating the row confers nothing there any more. ⛔ The refusals - // themselves are unchanged in every direction (this decides no verdict and - // no row is judged differently); what changes is that a walled operator is - // told the remedy that actually ends the emptiness. Saying only "restore the - // row" would send them round a loop that cannot terminate — the silent half - // of a loud migration, in the one message they get. - const walledRemedy = postureEnforcesWall(resolveTenancyPosture()) - ? ` ⚠️ This deployment runs a WALLED tenancy posture, where an unscoped '${ADMIN_FULL_ACCESS}' ` - + 'grant row is no longer an anchor for platform admin standing at all — restoring or ' - + 're-activating that row will NOT give this system an administrator. Declare one in ' - + `${PLATFORM_OWNER_EMAIL_ENV} (a verified account's address, comma-separated for several) ` - + 'instead; that is the only channel to platform admin standing under a wall.' - : ''; const known = await catalogSetNames(); const adminSetDeactivated = known.has(ADMIN_FULL_ACCESS) && (await readAdminSetSwitchedOff(op)); @@ -1323,25 +1317,25 @@ export function registerLastAdminGuard( }); const nowMs = Date.now(); // [ADR-0131 D5] The grants the anchor reads — the Default Organization's - // under `single`, beside the organization-less row. + // under `single`, and none under a wall. 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( `[LastAdminGuard] refused a ${words.noun} in an environment whose '${ADMIN_FULL_ACCESS}' ` + - `permission set is switched OFF — ${stranded.length} unscoped grant(s) confer nothing`, + `permission set is switched OFF — ${stranded.length} Default Organization grant(s) confer nothing`, ); throw refuse( `Refusing this ${words.noun}: this system recognises NO administrator because its ` + `'${ADMIN_FULL_ACCESS}' permission set is switched OFF in '${METADATA_ACTIVATION}' — ` + - `${stranded.length} unscoped, in-window '${USER_PERMISSION_SET}' grant(s) still point ` + + `${stranded.length} in-window Default Organization '${USER_PERMISSION_SET}' grant(s) still point ` + 'at it and confer nothing while it is off. That is not the bootstrap window, and ' + 'reading the resulting emptiness as "no administrator to protect" would switch this ' + `guard off for every other write too (${BREAK_GLASS_CITATION}). Switch the ` + `'${ADMIN_FULL_ACCESS}' permission set back on (its '${METADATA_ACTIVATION}' row's ` + "'active' back to true) — the grants naming it are still there — before writing the " + - 'identity tables again.' + walledRemedy, + 'identity tables again.', words.table, ); } @@ -1366,7 +1360,7 @@ export function registerLastAdminGuard( if (name !== undefined && known.has(name)) 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 + // Organization's grant; an organization-less one is nobody's), 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; @@ -1380,18 +1374,18 @@ export function registerLastAdminGuard( const words = OP_WORDS[op]; logger?.warn( `[LastAdminGuard] refused a ${words.noun} in an environment with no administrator that is ` + - `NOT a fresh install — ${dangling.length} dangling unscoped grant(s): ${dangling.join(', ')}`, + `NOT a fresh install — ${dangling.length} dangling Default Organization grant(s): ${dangling.join(', ')}`, ); throw refuse( `Refusing this ${words.noun}: this system recognises NO administrator, and this is not ` + - `the bootstrap window — ${dangling.length} unscoped, in-window '${USER_PERMISSION_SET}' ` + + `the bootstrap window — ${dangling.length} in-window Default Organization '${USER_PERMISSION_SET}' ` + 'grant(s) still name a permission set the security catalog does not hold, ' + `or name none yet (${dangling.join(', ')}). That is the state a REMOVED '${ADMIN_FULL_ACCESS}' ` + 'permission-set definition leaves behind: it un-makes every platform admin at once, and ' + 'reading the resulting emptiness as "no administrator to protect" would switch this guard ' + `off for every other write too (${BREAK_GLASS_CITATION}). Restore the ` + `'${ADMIN_FULL_ACCESS}' permission set — the grants naming it are still there — before ` + - 'writing the identity tables again.' + walledRemedy, + 'writing the identity tables again.', words.table, ); }; From fbf5356cc434463f9074567296b7d48c970b389b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:27:12 +0000 Subject: [PATCH 2/7] test(plugin-auth): the guard suites own their platform-admin grant by the Default Organization Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- .../__tests__/sqlite-engine-pool.testkit.ts | 38 +- ...last-admin-guard.activation-ledger.test.ts | 27 +- .../last-admin-guard.config-anchor.test.ts | 325 ++++++++++-------- .../src/last-admin-guard.re-pricing.test.ts | 36 +- .../plugin-auth/src/last-admin-guard.test.ts | 63 ++-- .../scim-deactivation-reconcile-user.test.ts | 46 ++- 6 files changed, 339 insertions(+), 196 deletions(-) diff --git a/packages/plugins/plugin-auth/src/__tests__/sqlite-engine-pool.testkit.ts b/packages/plugins/plugin-auth/src/__tests__/sqlite-engine-pool.testkit.ts index 553091fb6f6..b39e5052e10 100644 --- a/packages/plugins/plugin-auth/src/__tests__/sqlite-engine-pool.testkit.ts +++ b/packages/plugins/plugin-auth/src/__tests__/sqlite-engine-pool.testkit.ts @@ -21,7 +21,9 @@ * - the engine's memo of the install's organizations, which only an engine * write to `sys_organization` invalidates: the rows are deleted below the * engine, so a database that holds an organization row is refused rather - * than emptied; + * than emptied — unless the pool KEEPS the rows its boot wrote + * (`keepBootOrganizations`): they are then left in place, and a case that + * changed them is refused, so the memo still describes the table; * - the schema itself: a case that created or dropped a table leaves an * engine a fresh boot would not give, and the next `take()` refuses it; * - the write epoch and the log-once sets: counters and logging only, read by @@ -54,6 +56,12 @@ export interface BootedSqliteEngine { export interface SqliteEnginePoolOptions { /** Per-engine fixture state the suite keeps beside the database (a catalog set, say), reset with it. */ onReset?: (engine: E) => void | Promise; + /** + * Keep the `sys_organization` rows the boot wrote (a Default Organization, say) + * across resets instead of refusing them. A case that adds, changes or removes + * one is refused at the next `take()`, as a schema change is. + */ + keepBootOrganizations?: boolean; } export interface SqliteEnginePool { @@ -72,6 +80,8 @@ interface Entry { driver: SqlDriver; /** The tables the boot created, sorted. */ tables: readonly string[]; + /** The `sys_organization` rows the boot wrote, serialized, when the pool keeps them. */ + bootOrganizations?: string; held: boolean; } @@ -80,6 +90,11 @@ const ORGANIZATION_TABLE = 'sys_organization'; type Knex = ReturnType; +async function organizationRows(knex: Knex): Promise { + const rows = (await knex(ORGANIZATION_TABLE).select('*')) as Array>; + return JSON.stringify(rows.map((row) => JSON.stringify(row)).sort()); +} + async function tableNames(knex: Knex): Promise { const rows = (await knex('sqlite_master').where({ type: 'table' }).select('name')) as Array<{ name: string }>; return rows @@ -121,7 +136,11 @@ export function sqliteEnginePool( 'memory across a reset; boot that engine per case instead of pooling it.', ); } - const entry: Entry = { engine, driver, tables: await tableNames(driver.getKnex()), held: true }; + const tables = await tableNames(driver.getKnex()); + const entry: Entry = { engine, driver, tables, held: true }; + if (options.keepBootOrganizations && tables.includes(ORGANIZATION_TABLE)) { + entry.bootOrganizations = await organizationRows(driver.getKnex()); + } entries.push(entry); return entry; } @@ -138,7 +157,15 @@ export function sqliteEnginePool( 'Boot that case its own engine.', ); } - if (tables.includes(ORGANIZATION_TABLE)) { + const keptOrganizations = entry.bootOrganizations !== undefined; + if (keptOrganizations && (await organizationRows(knex)) !== entry.bootOrganizations) { + throw new Error( + `sqliteEnginePool: a case changed the ${ORGANIZATION_TABLE} rows this pool keeps from the boot, and the ` + + 'engine memoizes the organization set until an engine write to that object invalidates it. Boot that ' + + 'case its own engine.', + ); + } + if (tables.includes(ORGANIZATION_TABLE) && !keptOrganizations) { const [row] = (await knex(ORGANIZATION_TABLE).count({ n: '*' })) as Array<{ n: number | string }>; if (Number(row?.n ?? 0) > 0) { throw new Error( @@ -151,7 +178,10 @@ export function sqliteEnginePool( await knex.transaction(async (trx) => { // Checked at commit, when every table is empty: the delete order does not matter. await trx.raw('PRAGMA defer_foreign_keys = ON'); - for (const table of tables) await trx(table).del(); + for (const table of tables) { + if (keptOrganizations && table === ORGANIZATION_TABLE) continue; + await trx(table).del(); + } const [sequence] = (await trx('sqlite_master').where({ type: 'table', name: 'sqlite_sequence' })) as unknown[]; if (sequence) await trx('sqlite_sequence').del(); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts index e5e87ffeebd..fbd93fa816f 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts @@ -21,10 +21,11 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; -import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { context: { isSystem: true } } as const; const text = { type: 'text' as const }; +/** [ADR-0131 D5] The Default Organization (`slug = 'default'`), the owner of a grant that anchors under `single`. */ +const DEFAULT_ORG = 'org_default'; const objects = [ { @@ -49,6 +50,17 @@ const objects = [ role: { ...text, name: 'role' }, }, }, + // [ADR-0131 D5] The organization object, so the Default Organization the anchor reads exists. + { + name: 'sys_organization', + label: 'Organization', + managedBy: 'better-auth', + fields: { + id: { ...text, name: 'id', primaryKey: true }, + name: { ...text, name: 'name' }, + slug: { ...text, name: 'slug' }, + }, + }, { name: 'sys_user_permission_set', label: 'User Permission Set', @@ -117,10 +129,7 @@ async function boot(opts: Boot = { permissions: [ADMIN_FULL_ACCESS] }): Promise< await engine.init(); for (const o of objects) engine.registry.registerObject(o as never); await engine.syncSchemas(); - // The organization-less rows below (global sets, grants, identities) exist - // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column - // NOT NULL): this is that store. - await relaxEveryRegisteredTenantColumn(engine); + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); const guardEngine = opts.readThrough?.(engine) ?? (engine as unknown as LastAdminGuardEngine); if (opts.permissions) { const catalog = { permissions: opts.permissions.map((name) => ({ name })) }; @@ -135,7 +144,13 @@ async function seedGrantAdmin(engine: ObjectQL, id = 'usr_admin'): Promise await engine.insert('sys_user', { id, name: id, email: `${id}@corp.example`, banned: false }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: `ups_${id}`, user_id: id, permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS }, + { + id: `ups_${id}`, + user_id: id, + permission_set_id: 'ps_admin', + permission_set: ADMIN_FULL_ACCESS, + organization_id: DEFAULT_ORG, + }, SYSTEM, ); } diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts index b849e651846..94283bf3586 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts @@ -6,7 +6,7 @@ * declared administrator list. * * The platform-admin re-anchor gave `resolveAuthzContext` a second anchor - * beside the unscoped `admin_full_access` grant — a `sys_user` row whose own + * beside the Default Organization's `admin_full_access` grant — a `sys_user` row whose own * `email` is on `OS_PLATFORM_OWNER_EMAIL` AND whose `email_verified` reads * verified. Two consequences this file pins, because they are the two halves of * one invariant and each is silently wrong without the other: @@ -35,7 +35,7 @@ * * ⚠️ [#11663 L5] A THIRD consequence joined those two, and the suite at the end * of this file is it: the grant anchor is now POSTURE-KEYED. Under a walled - * posture the unscoped `admin_full_access` grant confers nothing, so the + * posture no `admin_full_access` grant confers anything, so the * enumeration must not count it — an enumeration that does would permit exactly * the write that ends the last CONFIG-anchored administrator's standing. Every * arm in this file therefore declares its posture rather than inheriting the @@ -67,6 +67,8 @@ const MULTI_ORG_ENV = 'OS_MULTI_ORG_ENABLED'; const SYSTEM = { context: { isSystem: true } } as const; const OWNER = 'owner@corp.example'; const SECOND = 'second@corp.example'; +/** [ADR-0131 D5] The Default Organization (`slug = 'default'`), the owner of a grant that anchors under `single`. */ +const DEFAULT_ORG = 'org_default'; const sysUser = { name: 'sys_user', @@ -96,6 +98,18 @@ const sysMember = { }, }; +/** [ADR-0131 D5] The organization object, so the Default Organization the anchor reads exists. */ +const sysOrganization = { + name: 'sys_organization', + label: 'Organization', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + slug: { name: 'slug', type: 'text' as const }, + }, +}; + const sysPermissionSet = { name: 'sys_permission_set', label: 'Permission Set', @@ -195,21 +209,22 @@ function requestPosture(value: 'single' | 'group' | 'isolated' | undefined): voi else process.env[POSTURE_ENV] = value; } -async function boot(opts: { unguarded?: boolean } = {}): Promise { +async function boot(opts: { unguarded?: boolean; legacyStore?: boolean } = {}): Promise { const engine = new ObjectQL(); engines.push(engine); const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); driverOf.set(engine, driver); engine.registerDriver(driver, true); await engine.init(); - for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { + for (const o of [sysUser, sysMember, sysOrganization, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { engine.registry.registerObject(o as never); } await engine.syncSchemas(); - // The organization-less rows below (global sets, grants, identities) exist - // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column - // NOT NULL): this is that store. - await relaxEveryRegisteredTenantColumn(engine); + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); + // An organization-less row ({@link seedLegacyRow}) exists only on a store + // built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): the + // cases that seed one ask for that store. + if (opts.legacyStore) await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D4] The set a grant names is the catalog's definition. bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); if (!opts.unguarded) { @@ -222,13 +237,12 @@ async function boot(opts: { unguarded?: boolean } = {}): Promise { const driverOf = new WeakMap(); /** - * [#11663 L5] Seed an organization-less legacy row — the unscoped - * `admin_full_access` grant and its catalog row — as a database written before - * the walled half of ADR-0131 C8 stage 8b holds it. Since that lift a system - * write can no longer create such a row under a wall (the engine refuses the - * organization-less insert), so it goes straight through the driver. Under - * `single` with no organization object the engine left the same row NULL, so - * the rig is unchanged there. + * [#11663 L5] Seed an organization-less legacy row — an `admin_full_access` + * grant owned by no organization, and its catalog row — as a database written + * before 18.x holds it (boot with `legacyStore`). No system write can create + * such a row any more: the engine derives the Default Organization under + * `single` and refuses the insert under a wall (ADR-0131 D9), so it goes + * straight through the driver. */ async function seedLegacyRow(engine: ObjectQL, object: string, row: Record): Promise { await driverOf.get(engine)!.create(object, row as never); @@ -285,7 +299,13 @@ describe('[#11663 L2] the enumeration counts CONFIG-derived administrators', () await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_grant', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, + { + id: 'ups_1', + user_id: 'usr_grant', + permission_set_id: 'ps_a', + permission_set: ADMIN_FULL_ACCESS, + organization_id: DEFAULT_ORG, + }, SYSTEM, ); @@ -344,7 +364,7 @@ describe('[#11663 L2] the FIFTH write shape is judged', () => { expect((await readUser(engine, 'usr_owner'))?.email).toBe('personal@example.com'); }); - it('ALLOWS it when the same user also holds the legacy grant — under `single`', async () => { + it('ALLOWS it when the same user also holds the Default Organization\'s grant — under `single`', async () => { // Standing that survives the write through the OTHER anchor is standing // that survives — the enumeration is one function over both. // @@ -360,7 +380,13 @@ describe('[#11663 L2] the FIFTH write shape is judged', () => { await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); await engine.insert( 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_owner', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, + { + id: 'ups_1', + user_id: 'usr_owner', + permission_set_id: 'ps_a', + permission_set: ADMIN_FULL_ACCESS, + organization_id: DEFAULT_ORG, + }, SYSTEM, ); @@ -419,71 +445,110 @@ describe('[#11663 L2] reverse verification — the same writes on an UNGUARDED e // ─────────────────────────────────────────────────────────────────────────── /** - * [#11663 L5] The grant anchor is POSTURE-KEYED, and this enumeration is keyed - * with it — one anchor, two readers, ONE answer. + * [#11663 L5, ADR-0131 D1/D5] The grant anchor is POSTURE-KEYED and + * OWNER-KEYED, and this enumeration is keyed with it — one anchor, two + * readers, ONE answer. * - * `resolveAuthzContext` §6b stopped deriving `PLATFORM_ADMIN` from an unscoped - * `admin_full_access` grant under a walled posture. This guard reads the SAME - * anchor to answer "who administers this environment", and its header binds it - * to that derivation: 「the enumeration must answer the SAME question - * `resolveAuthzContext` does」. Keyed on one side only, the two disagree on a - * walled rig that still holds a legacy row — and the guard then believes an - * administrator remains when none does. + * `resolveAuthzContext` §6b derives `PLATFORM_ADMIN` from an `admin_full_access` + * grant only under `single`, and only from the grant the Default Organization + * owns (`grantAnchorsPlatformStanding`): under a walled posture no grant + * anchors, and an organization-less grant — the legacy row a store built + * before 18.x may still hold — anchors nothing in any posture. This guard reads + * the SAME anchor to answer "who administers this environment", and its header + * binds it to that derivation: 「the enumeration answers the SAME question the + * derivation does」. Keyed on one side only, the two disagree on a rig that + * holds such a row — and the guard then believes an administrator remains when + * none does. * - * ⛔ BOTH directions are pinned, deliberately, and the `single` control is not - * decoration: keying the enumeration is only correct if `single` keeps counting - * that row. Under `single` the row is still that rig's anchor (Choice 4A), its - * zero-config first-user promotion still mints it, and #11979 is the card that - * disposes of it. A one-sided pin would let a later edit key `single` too and - * stay green, which is the zero-config lockout this card exists NOT to ship. + * ⛔ BOTH keys are pinned in BOTH directions, and the controls are not + * decoration: the Default Organization's grant must keep counting under + * `single` (it is that rig's anchor, the row its zero-config first-user + * promotion writes — Choice 4A), and it must stop counting under a wall. A + * one-sided pin would let a later edit stop counting the Default Organization's + * grant everywhere and stay green, which is the zero-config lockout Choice 4A + * forbids; or count the organization-less row again, which is the fail-open + * this file now refuses. * - * ⚠️ Direction of the change, stated because it is easy to read backwards: under - * a wall the guard now refuses MORE (it protects the config-anchored - * administrator it used to let a write take away) and refuses WRONGLY less (it - * stops claiming that revoking an inert row removes the last administrator). - * Both are narrowings toward the already-declared model. + * ⚠️ Direction of the change, stated because it is easy to read backwards: the + * guard now refuses MORE (it protects the config-anchored administrator it used + * to let a write take away while counting a holder nobody else recognises) and + * refuses WRONGLY less (it stops claiming that revoking an inert row removes the + * last administrator). Both are narrowings toward the declared model. */ -describe('[#11663 L5] under a WALLED posture the legacy grant is not an administrator', () => { - /** A rig with one CONFIG-anchored admin and one LEGACY grant holder. */ - async function bootTwoAnchors(): Promise { - const engine = await boot(); - await seedUser(engine, 'usr_owner', OWNER, true); +describe('[#11663 L5, ADR-0131 D5] a grant anchors only when the Default Organization owns it, and only under `single`', () => { + type Owner = 'default-organization' | 'none'; + + /** + * One CONFIG-anchored admin and one GRANT holder. `none` is the legacy + * organization-less row, seeded beneath the engine on a pre-18 store; the + * Default Organization's grant goes through the engine as the promotion + * writes it. + */ + async function bootGrantHolder(owner: Owner, withConfigOwner: boolean): Promise { + const engine = await boot({ legacyStore: owner === 'none' }); + if (withConfigOwner) await seedUser(engine, 'usr_owner', OWNER, true); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); - await seedLegacyRow( - engine, - 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - ); + await seedGrant(engine, owner); return engine; } - it('⭐ REFUSES the write that ends the last config-anchored standing — the legacy holder is no longer a survivor', async () => { - // The blocking half. Before the enumeration was keyed, the guard counted - // `usr_legacy` and waved this write through, leaving a walled deployment - // with NO platform administrator at all: §6b no longer recognises that - // holder, so nobody was left. Refusing it is the guard's entire job. - for (const walled of ['group', 'isolated'] as const) { - requestPosture(walled); - declare(OWNER); - const engine = await bootTwoAnchors(); + async function seedGrant(engine: ObjectQL, owner: Owner): Promise { + const grant = { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }; + if (owner === 'none') { + await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); + await seedLegacyRow(engine, 'sys_user_permission_set', grant); + } else { + await engine.insert( + 'sys_permission_set', + { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true, organization_id: DEFAULT_ORG }, + SYSTEM, + ); + await engine.insert('sys_user_permission_set', { ...grant, organization_id: DEFAULT_ORG }, SYSTEM); + } + } - await expect( - engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), - walled, - ).rejects.toThrow(/OS_PLATFORM_OWNER_EMAIL/); - expect((await readUser(engine, 'usr_owner'))?.email_verified, walled).toBeTruthy(); + it('⭐ REFUSES the write that ends the last config-anchored standing under a wall — neither grant holder is a survivor', async () => { + // The blocking half. Counting `usr_legacy` would wave this write through, + // leaving a walled deployment with NO platform administrator at all: §6b + // recognises no grant holder there, so nobody would be left. + for (const walled of ['group', 'isolated'] as const) { + for (const owner of ['default-organization', 'none'] as const) { + requestPosture(walled); + declare(OWNER); + const engine = await bootGrantHolder(owner, true); + + await expect( + engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), + `${walled} / ${owner}`, + ).rejects.toThrow(/OS_PLATFORM_OWNER_EMAIL/); + expect((await readUser(engine, 'usr_owner'))?.email_verified, `${walled} / ${owner}`).toBeTruthy(); + } } }); - it('…and `single` ALLOWS the identical write on the identical fixture — the control that keeps the arm above honest', async () => { - // Same fixture, same write, opposite answer, and the ONLY difference is the - // posture. Without this the arm above would pass just as well on a guard - // that had stopped counting the grant anchor everywhere — which is the + it('⭐ REFUSES it under `single` too when the surviving grant is organization-less — that row anchors nothing (ADR-0131 D1)', async () => { + // The fail-open this card closed. Since the resolver stopped reading the + // organization-less row (18.x step 7a-2), its holder is no administrator + // anywhere; a guard that still counted it let this write take the + // deployment's only platform administrator. + requestPosture('single'); + declare(OWNER); + const engine = await bootGrantHolder('none', true); + + await expect( + engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), + ).rejects.toThrow(/OS_PLATFORM_OWNER_EMAIL/); + expect((await readUser(engine, 'usr_owner'))?.email_verified).toBeTruthy(); + }); + + it('…and `single` ALLOWS the identical write when the Default Organization owns the grant — the control that keeps both arms above honest', async () => { + // Same write, opposite answer; the only differences are the posture and + // the owner. Without this the arms above would pass just as well on a + // guard that had stopped counting the grant anchor everywhere — the // zero-config lockout Choice 4A forbids. requestPosture('single'); declare(OWNER); - const engine = await bootTwoAnchors(); + const engine = await bootGrantHolder('default-organization', true); await expect( engine.update('sys_user', { id: 'usr_owner', email_verified: false }, SYSTEM), @@ -491,43 +556,35 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ expect((await readUser(engine, 'usr_owner'))?.email_verified).toBeFalsy(); }); - it('⛔ stops refusing WRONGLY: revoking the now-inert grant is not "removing the last administrator"', async () => { - // The inverse. On a walled rig whose only grant-anchored holder is the - // legacy row, that row confers nothing — so a write that revokes it takes - // no standing away and must not be refused as though it did. Before the - // key, this same delete was refused with a message naming the last - // administrator. - requestPosture('isolated'); - declare(undefined); - const engine = await boot(); - await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); - await seedLegacyRow( - engine, - 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - ); + it('⛔ stops refusing WRONGLY: revoking an inert grant is not "removing the last administrator"', async () => { + // The inverse. Where the grant confers nothing — any grant under a wall, + // the organization-less row under `single` — revoking it takes no standing + // away and must not be refused as though it did. + const inert: Array<['single' | 'isolated', Owner]> = [ + ['isolated', 'default-organization'], + ['isolated', 'none'], + ['single', 'none'], + ]; + for (const [posture, owner] of inert) { + requestPosture(posture); + declare(undefined); + const engine = await bootGrantHolder(owner, false); - await expect( - engine.delete('sys_user_permission_set', { where: { id: 'ups_1' }, ...SYSTEM }), - ).resolves.toBeDefined(); + await expect( + engine.delete('sys_user_permission_set', { where: { id: 'ups_1' }, ...SYSTEM }), + `${posture} / ${owner}`, + ).resolves.toBeDefined(); + } }); - it('…and `single` still REFUSES that same revocation — the row is that rig\'s anchor', async () => { - // The other side of the same control. Under `single` the legacy holder IS - // the administrator, so revoking the grant empties the environment and the - // guard refuses. ⛔ If this ever goes green-by-allowing, the key has leaked - // onto the default posture. + it('…and `single` still REFUSES that revocation when the Default Organization owns the grant — the row is that rig\'s anchor', async () => { + // The other side of the same control. Under `single` the Default + // Organization's grant holder IS the administrator, so revoking the grant + // empties the environment and the guard refuses. ⛔ If this ever goes + // green-by-allowing, the key has leaked onto the anchor itself. requestPosture('single'); declare(undefined); - const engine = await boot(); - await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); - await seedLegacyRow( - engine, - 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - ); + const engine = await bootGrantHolder('default-organization', false); await expect( engine.delete('sys_user_permission_set', { where: { id: 'ups_1' }, ...SYSTEM }), @@ -554,51 +611,45 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ ).resolves.toBeDefined(); }); - it('the emptied-not-fresh refusal names the remedy that WORKS under a wall', async () => { - // [#11663 L5] The refusal itself is unchanged — a switched-off - // `admin_full_access` with stranded unscoped grants is still the - // evidence, and the write is still refused. What changed is that 「restore - // the row」 stopped ending the emptiness on a walled rig, so the message - // names the channel that does. - requestPosture('isolated'); - declare(undefined); - const engine = await boot(); - await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await switchAdminSetOff(engine); - await seedLegacyRow( - engine, - 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - ); + it('a switched-off set is emptied-not-fresh evidence only through a grant that anchors', async () => { + // [#6084, ADR-0131 D5] The evidence is a stranded grant the anchor reads. + // Under a wall no grant anchors (standing is configuration only, and + // restoring a row could not end the emptiness), and an organization-less + // grant anchors nothing anywhere — so neither is evidence, and the write is + // read as the bootstrap window it is: nobody holds standing to lose. + const notEvidence: Array<['single' | 'isolated', Owner]> = [ + ['isolated', 'default-organization'], + ['isolated', 'none'], + ['single', 'none'], + ]; + for (const [posture, owner] of notEvidence) { + requestPosture(posture); + declare(undefined); + const engine = await boot({ legacyStore: owner === 'none' }); + await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); + await switchAdminSetOff(engine); + await seedGrant(engine, owner); - await expect( - engine.delete('sys_user', { where: { id: 'usr_legacy' }, ...SYSTEM }), - ).rejects.toThrow(/OS_PLATFORM_OWNER_EMAIL/); + await expect( + engine.delete('sys_user', { where: { id: 'usr_legacy' }, ...SYSTEM }), + `${posture} / ${owner}`, + ).resolves.toBeDefined(); + } }); - it('…and under `single` that same refusal does NOT — the clause is conditional, not unconditional prose', async () => { - // The control for the clause above: it must come from the posture, not - // from the message having grown a sentence for everybody. + it('…and under `single` the Default Organization\'s stranded grant IS the evidence — refused, naming the switch', async () => { + // The control for the arm above: the refusal still fires where the grant + // anchors, so the permits above come from the key and not from a guard + // that stopped reading the ledger. requestPosture('single'); declare(undefined); const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); await switchAdminSetOff(engine); - await seedLegacyRow( - engine, - 'sys_user_permission_set', - { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - ); + await seedGrant(engine, 'default-organization'); - let message = ''; - try { - await engine.delete('sys_user', { where: { id: 'usr_legacy' }, ...SYSTEM }); - } catch (err) { - message = String((err as Error)?.message ?? err); - } - // The refusal really fired — without this the absence below would be the - // absence of any message at all. - expect(message).toMatch(/switched OFF/); - expect(message).not.toMatch(/OS_PLATFORM_OWNER_EMAIL/); + await expect( + engine.delete('sys_user', { where: { id: 'usr_legacy' }, ...SYSTEM }), + ).rejects.toThrow(/switched OFF/); }); }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts index 4fa2036206f..1e659369535 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts @@ -14,7 +14,7 @@ * off in the activation ledger — write shape (4), which un-makes every * grant-anchored platform admin in one write (ADR-0131 D3) — no longer * empties a population that contains a config-anchored administrator, so - * those writes are PERMITTED there. Same for deleting the legacy grant row + * those writes are PERMITTED there. Same for deleting the last grant row * itself. * - **KEPT where the grant anchor is load-bearing.** With no declared * administrators (`single` posture under Choice 4A, and P5's honoured @@ -40,7 +40,6 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; -import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; /** [ADR-0131 D3, ADR-0126 §4] The activation ledger a permission set is switched off in. */ const sysMetadataActivation = { @@ -58,6 +57,20 @@ const sysMetadataActivation = { const ENV = 'OS_PLATFORM_OWNER_EMAIL'; const SYSTEM = { context: { isSystem: true } } as const; const OWNER = 'owner@corp.example'; +/** [ADR-0131 D5] The Default Organization (`slug = 'default'`), the owner of a grant that anchors under `single`. */ +const DEFAULT_ORG = 'org_default'; + +/** [ADR-0131 D5] The organization object, so the Default Organization the anchor reads exists. */ +const sysOrganization = { + name: 'sys_organization', + label: 'Organization', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + slug: { name: 'slug', type: 'text' as const }, + }, +}; const sysUser = { name: 'sys_user', @@ -169,14 +182,11 @@ async function boot(): Promise { true, ); await engine.init(); - for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { + for (const o of [sysUser, sysMember, sysOrganization, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { engine.registry.registerObject(o as never); } await engine.syncSchemas(); - // The organization-less rows below (global sets, grants, identities) exist - // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column - // NOT NULL): this is that store. - await relaxEveryRegisteredTenantColumn(engine); + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); // [ADR-0131 D3/D4] The set a grant names is the catalog's definition. bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard-re-pricing' }); @@ -185,7 +195,7 @@ async function boot(): Promise { /** * A grant-anchored administrator: verified user + the catalog's - * `admin_full_access` (bound in `boot`) + an unscoped in-window grant. The pre-re-anchor shape of "the last + * `admin_full_access` (bound in `boot`) + an in-window grant the Default Organization owns. The pre-re-anchor shape of "the last * platform admin". */ async function seedGrantAdmin(engine: ObjectQL, userId = 'usr_grant'): Promise { @@ -197,7 +207,13 @@ async function seedGrantAdmin(engine: ObjectQL, userId = 'usr_grant'): Promise { + it('deleting the LAST grant row is permitted, and lands', async () => { declare(OWNER); const engine = await boot(); await seedConfigAdmin(engine); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts index 14789fee1fb..827235b1244 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts @@ -66,7 +66,6 @@ import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js import { sqliteEnginePool, type BootedSqliteEngine } from './__tests__/sqlite-engine-pool.testkit.js'; import { admin } from 'better-auth/plugins/admin'; import { organization } from 'better-auth/plugins/organization'; -import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; // --------------------------------------------------------------------------- // Fixtures @@ -153,6 +152,23 @@ const sysMetadataActivation = { }, }; +/** + * [ADR-0131 D5] The organization object, so the Default Organization the anchor + * reads exists: a platform-admin grant anchors standing only when the Default + * Organization owns it, exactly as `resolveAuthzContext` reads it. Every engine + * this file boots holds one, {@link DEFAULT_ORG}, written at boot. + */ +const sysOrganization = { + name: 'sys_organization', + label: 'Organization', + managedBy: 'better-auth', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + slug: { name: 'slug', type: 'text' as const }, + }, +}; + const sysUserPermissionSet = { name: 'sys_user_permission_set', label: 'User Permission Set', @@ -170,6 +186,8 @@ const sysUserPermissionSet = { const SYSTEM = { context: { isSystem: true } } as const; const ORG = 'org_1'; +/** [ADR-0131 D5] The Default Organization (`slug = 'default'`), the owner of every platform-admin grant. */ +const DEFAULT_ORG = 'org_default'; const PS_ADMIN = 'ps_admin_full_access'; /** @@ -269,6 +287,7 @@ afterEach(async () => { */ const defaultEngines = sqliteEnginePool(() => bootEngine({}), { onReset: (engine) => catalogOf(engine).clear(), + keepBootOrganizations: true, }); afterAll(defaultEngines.destroy); @@ -287,14 +306,11 @@ async function bootEngine(opts: BootOptions, track?: ObjectQL[]): Promise(); catalogs.set(engine, names); const catalog = () => ({ permissions: [...names].map((name) => ({ name })) }); @@ -348,7 +364,14 @@ async function seedUser( ); } if (extra.platformAdmin || extra.grant) { - const grant: Record = { id: `ups_${id}`, user_id: id, permission_set_id: PS_ADMIN, ...(extra.grant ?? {}) }; + // [ADR-0131 D5] Owned by the Default Organization unless the case names another owner. + const grant: Record = { + id: `ups_${id}`, + user_id: id, + permission_set_id: PS_ADMIN, + organization_id: DEFAULT_ORG, + ...(extra.grant ?? {}), + }; if (!('permission_set' in grant)) grant.permission_set = SET_NAME_OF[String(grant.permission_set_id)]; await engine.insert('sys_user_permission_set', grant, SYSTEM); } @@ -417,12 +440,12 @@ describe('[#5892] break-glass: the last unbanned administrator cannot be banned' expect(await bannedFlag(engine, 'usr_member')).toBeTruthy(); }); - it('a platform admin (unscoped admin_full_access) counts, a SCOPED grant does not', async () => { - // `usr_platform` holds the org-less grant → platform admin. + it('a platform admin (the Default Organization\'s admin_full_access) counts, a SCOPED grant does not', async () => { + // `usr_platform` holds the Default Organization's grant → platform admin. await seedUser(engine, 'usr_platform', { platformAdmin: true }); // `usr_scoped` holds the SAME permission set scoped to an org → a tenant - // admin, not the environment's break-glass account (ADR-0068 D2 reads the - // unscoped grant only). + // admin, not the environment's break-glass account (ADR-0131 D5 reads the + // Default Organization's grant only). await seedUser(engine, 'usr_scoped', { grant: { organization_id: ORG } }); await expect(ban(engine, 'usr_scoped')).resolves.toBeTruthy(); @@ -765,7 +788,7 @@ describe('[#5941] break-glass: the last unbanned administrator cannot be DELETED expect(await userExists(engine, 'usr_nobody')).toBe(false); }); - it('a platform admin (unscoped admin_full_access) counts, a SCOPED grant does not', async () => { + it('a platform admin (the Default Organization\'s admin_full_access) counts, a SCOPED grant does not', async () => { await seedUser(engine, 'usr_platform', { platformAdmin: true }); await seedUser(engine, 'usr_scoped', { grant: { organization_id: ORG } }); @@ -1182,8 +1205,8 @@ describe('[#5978] path 1 — downgrading the last administrator\'s sys_member ro await seedUser(engine, 'usr_owner', { role: 'owner' }); await seedUser(engine, 'usr_platform', { platformAdmin: true }); - // The survivor need not be an org admin: an unscoped `admin_full_access` - // grant is the other half of the same enumeration. + // The survivor need not be an org admin: the Default Organization's + // `admin_full_access` grant is the other half of the same enumeration. await expect(setRole('mem_usr_owner', 'member')).resolves.toBeTruthy(); }); @@ -1318,8 +1341,8 @@ describe('[#5978] path 3 — revoking the last administrator\'s admin_full_acces await seedUser(engine, 'usr_platform', { platformAdmin: true }); // The row survives and still points at `admin_full_access`; it just stops - // being the UNSCOPED grant `resolveAuthzContext` derives `platform_admin` - // from. Same end state, no delete anywhere. + // being the Default Organization's grant `resolveAuthzContext` derives + // `platform_admin` from. Same end state, no delete anywhere. await expect(editGrant('ups_usr_platform', { organization_id: ORG })).rejects.toThrow( /Refusing this grant change/, ); @@ -1328,7 +1351,7 @@ describe('[#5978] path 3 — revoking the last administrator\'s admin_full_acces { where: { id: 'ups_usr_platform' } }, SYSTEM, ); - expect(row?.organization_id).toBeFalsy(); + expect(row?.organization_id).toBe(DEFAULT_ORG); }); it('EXPIRING the last grant is refused (ADR-0091 window, consumed as-is)', async () => { @@ -1943,7 +1966,7 @@ describe('[ADR-0131 D3/D4] the sys_permission_set row is no standing input', () // // So "zero administrators" is split into the two states it was conflating: a // genuinely fresh environment (permitted, unchanged) and one that was emptied -// (refused). The evidence is a DANGLING unscoped in-window grant — one naming +// (refused). The evidence is a DANGLING in-window Default Organization grant — one naming // a set the catalog does not hold. // --------------------------------------------------------------------------- @@ -2022,7 +2045,7 @@ describe('[#6084] a zero-administrator reading is no longer automatically the bo await expect(switchOff(engine)).resolves.toBeDefined(); }); - it('an unscoped grant whose permission set the catalog still HOLDS is not evidence of a wipe', async () => { + it('a Default Organization grant whose permission set the catalog still HOLDS is not evidence of a wipe', async () => { const engine = await boot(); await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_a', { grant: { permission_set_id: 'ps_member' } }); @@ -2115,7 +2138,7 @@ describe('[#8613] a SWITCHED-OFF break-glass set is an emptied environment, not await expect(ban(engine, 'usr_platform')).rejects.toThrow(/last administrator/i); }); - it('a switched-off set nobody holds an unscoped grant to is NOT evidence', async () => { + it('a switched-off set nobody holds a Default Organization grant to is NOT evidence', async () => { const engine = await boot({ unguarded: true }); await seedAdminPermissionSet(engine); await seedUser(engine, 'usr_first', { role: 'member' }); diff --git a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts index 4f09ab5bb2a..e57a9298b6d 100644 --- a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts +++ b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts @@ -99,7 +99,6 @@ import '@better-auth/core/context'; import '@better-auth/core/error'; import '@better-auth/oauth-provider'; import '@better-auth/scim'; -import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; /** * [#23184] Every sign-in below verifies the password the case attached, which @@ -167,6 +166,8 @@ const sysUserPermissionSet = { }; const PS_ADMIN = 'ps_admin_full_access'; +/** [ADR-0131 D5] The Default Organization (`slug = 'default'`), the owner of the platform-admin grant. */ +const DEFAULT_ORG = 'org_default'; // [#23184] A case that moved the wall clock with `passTime()` gets the real // clock back before anything else runs; a no-op for every other case. @@ -182,7 +183,7 @@ afterEach(() => { * every case. The manager and the SCIM credential stay per case, built fresh * by {@link boot} on the emptied database. */ -const enginePool = sqliteEnginePool(bootEngine); +const enginePool = sqliteEnginePool(bootEngine, { keepBootOrganizations: true }); afterEach(enginePool.release); afterAll(enginePool.destroy); @@ -211,9 +212,8 @@ async function boot(): Promise { plugins: { scim: true, organization: true }, } as never); - // [C8 stage 8b] It carries an organization: under `single` an organization-less - - // system insert is refused while the install holds none, and this rig holds none. + // [C8 stage 8b] It carries an organization of its own: a system insert names + // its owner (ADR-0131 D9) rather than leaning on the Default Organization. // The verifier reads the digest alone, so the organization changes nothing it answers. @@ -242,10 +242,9 @@ async function bootEngine(): Promise { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } await engine.syncSchemas(); - // The organization-less rows below (global sets, grants, identities) exist - // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column - // NOT NULL): this is that store. - await relaxEveryRegisteredTenantColumn(engine); + // [ADR-0131 D5] The Default Organization the platform-admin anchor is owned by, + // as the `single` boot invariant creates it before any user exists. + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); // [ADR-0131 D3/D4] `admin_full_access` is a catalog definition — what the // break-glass guard, like the resolver, resolves a grant's set name to. bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); @@ -405,25 +404,34 @@ async function expectSignInBanned(h: Harness, email: string): Promise { expect(body.code).toBe('BANNED_USER'); } -/** Give `userId` unscoped `admin_full_access` — a platform administrator. */ +/** + * Give `userId` the Default Organization's `admin_full_access` grant — a + * platform administrator, the row the `single` first-user promotion writes + * (ADR-0131 D5). + */ async function makePlatformAdmin(h: Harness, userId: string): Promise { const existing = await h.engine.findOne( 'sys_permission_set', { where: { id: PS_ADMIN }, fields: ['id'] }, SYSTEM, ); - // [C8 stage 8b] The legacy organization-less catalogue row and the unscoped - // platform-admin grant go in beneath the engine: under `single` the engine - // refuses an organization-less platform insert while the install holds no - // organization (ADR-0131 D9), and the engine pool keeps none. - const residue = (object: string, row: Record) => - (h.engine as any).getDriver(object).create(object, row); if (!existing) { - await residue('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); + await h.engine.insert( + 'sys_permission_set', + { id: PS_ADMIN, name: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }, + SYSTEM, + ); } - await residue( + await h.engine.insert( 'sys_user_permission_set', - { id: `ups_${userId}`, user_id: userId, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS }, + { + id: `ups_${userId}`, + user_id: userId, + permission_set_id: PS_ADMIN, + permission_set: ADMIN_FULL_ACCESS, + organization_id: DEFAULT_ORG, + }, + SYSTEM, ); } From 4beebb57263bf73f8d0907e658fd1bebd0e4db2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:39:30 +0000 Subject: [PATCH 3/7] test(plugin-auth): the bootstrap suites own the promotion's grant by the Default Organization Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- .../plugin-auth/src/auth-plugin.test.ts | 89 +++++++++++---- .../src/default-org-bootstrap-once.test.ts | 10 +- .../src/ensure-default-organization.test.ts | 103 ++++++++++++++---- 3 files changed, 156 insertions(+), 46 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 7ff191d5338..3575a0ba015 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -8,6 +8,30 @@ import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objec import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; import { SettingsService, brandingSettingsManifest } from '@objectstack/service-settings'; +/** + * [ADR-0131 D3/D5] The `single` first-user promotion's grant, owned by the + * Default Organization the boot invariant creates before any user: its owner is + * read off `tables` as the Default Organization stands (`slug = 'default'`, else + * the only one) the first time one exists, and kept from then on — so the + * fixture holds the owner `start()` gives it rather than an organization-less + * row no reader anchors on, and a Default Organization deleted later leaves the + * grant naming the organization that is gone, as a stored row would. + */ +function promotionGrant(tables: Record, userId: string): Record { + let owner: string | undefined; + return { + id: 'ups1', + user_id: userId, + permission_set_id: 'ps_admin', + permission_set: 'admin_full_access', + get organization_id() { + const orgs = tables.sys_organization ?? []; + owner ??= (orgs.find((o) => o.slug === 'default') ?? (orgs.length === 1 ? orgs[0] : undefined))?.id; + return owner; + }, + }; +} + describe('AuthPlugin', () => { let mockContext: PluginContext; let authPlugin: AuthPlugin; @@ -1438,12 +1462,11 @@ describe('AuthPlugin', () => { const makeQl = () => { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], - sys_user_permission_set: [ - { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, - ], + sys_user_permission_set: [], sys_member: [], sys_organization: [], }; + tables.sys_user_permission_set.push(promotionGrant(tables, 'u1')); const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); @@ -1693,9 +1716,7 @@ describe('AuthPlugin', () => { const makeQl = () => { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], - sys_user_permission_set: [ - { id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, - ], + sys_user_permission_set: [], // The platform admin already exists; the default-org bootstrap binds it // as `owner` on kernel:ready. A member-less seeded user is added later. sys_user: [{ id: 'admin' }], @@ -1704,6 +1725,7 @@ describe('AuthPlugin', () => { // The deployment ledger the one-time pass records itself in. sys_migration: [], }; + tables.sys_user_permission_set.push(promotionGrant(tables, 'admin')); const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); @@ -1836,17 +1858,30 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_organization).toHaveLength(0); expect(ql.tables.sys_migration).toHaveLength(0); - // The admin's grant lands: the bootstrap creates the organization, and - // that moment runs the pass — no restart, no app:seeded. - ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }); - await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' }); - await vi.waitFor(() => { - expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' }); - }); - expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([ - 'adr-0093-default-org-owner-bind', - 'adr-0093-membership-backfill', - ]); + // The declared owner's verified account lands: the bootstrap creates the + // organization, and that moment runs the pass — no restart, no + // app:seeded. [ADR-0131 D5] The config anchor is the population that + // needs no organization first; a grant anchors only once the Default + // Organization owns it. + const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + try { + Object.assign(ql.tables.sys_user.find((u: any) => u.id === 'admin'), { + email: 'operator@corp.example', + email_verified: true, + }); + await fireBootstrapWrite({ object: 'sys_user', operation: 'update', data: { email_verified: true } }); + await vi.waitFor(() => { + expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' }); + }); + expect(ql.tables.sys_migration.map((r: any) => r.id).sort()).toEqual([ + 'adr-0093-default-org-owner-bind', + 'adr-0093-membership-backfill', + ]); + } finally { + if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner; + } }); it('unreadable ledger + an existing default organization + an unbound admin: the admin is NOT bound', async () => { @@ -1927,9 +1962,23 @@ describe('AuthPlugin', () => { expect(ql.tables.sys_migration.map((r: any) => r.id)).toContain('adr-0093-default-org-owner-bind'); ql.tables.sys_organization.length = 0; ql.tables.sys_member.length = 0; - await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' }); - expect(ql.tables.sys_organization).toHaveLength(1); - expect(ql.tables.sys_member).toHaveLength(0); + // [ADR-0131 D5] The grant still names the organization that is gone, so + // it anchors nobody now; the declared, verified owner is who the + // bootstrap recreates the organization for. + const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + try { + Object.assign(ql.tables.sys_user.find((u: any) => u.id === 'admin'), { + email: 'operator@corp.example', + email_verified: true, + }); + await fireBootstrapWrite({ object: 'sys_user', operation: 'insert' }); + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_member).toHaveLength(0); + } finally { + if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner; + } }); it('once the pass is recorded, a later trigger binds nobody', async () => { diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts index 63050e629a6..2bae90b210d 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.test.ts @@ -66,12 +66,14 @@ type Row = Record; function rig(seed: Partial> = {}) { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], - // The platform admin `u1`, through the legacy grant anchor `single` reads - // — by the set's name, which every platform grant writer stores (ADR-0131 D4). + // The platform admin `u1`, through the grant anchor `single` reads — by the + // set's name, which every platform grant writer stores (ADR-0131 D4), and + // owned by the Default Organization the boot invariant created before any + // user (ADR-0131 D3/D5). sys_user_permission_set: [ - { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_d' }, ], - sys_organization: [], + sys_organization: [{ id: 'org_d', slug: 'default' }], sys_member: [], sys_migration: [], ...seed, 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 2bdcd92e581..5f8f58b3bfb 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -73,14 +73,39 @@ function requestPosture(value: 'single' | 'group' | 'isolated' | undefined): voi type Row = Record; +/** [ADR-0131 D5] The Default Organization (`slug = 'default'`). */ +const DEFAULT_ORG = 'org_default'; + +/** + * A rig whose only administrator is CONFIG-anchored and which holds no + * organization yet: the one population the helper creates the Default + * Organization for (a walled bootstrap through `@objectstack/organizations`, or + * a `single` rig whose boot invariant has not run). A grant-anchored admin + * needs the Default Organization to exist already (ADR-0131 D5), so it never + * reaches the create branch. + */ +const CONFIG_OWNER = 'owner@corp.example'; +function makeConfigOnlyQl(seed: Partial> = {}) { + declare(CONFIG_OWNER); + return makeQl({ + sys_organization: [], + sys_user_permission_set: [], + sys_user: [{ id: 'u1', email: CONFIG_OWNER, email_verified: true }], + ...seed, + }); +} + function makeQl(seed: Partial> = {}) { const tables: Record = { sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], + // [ADR-0131 D3/D5] The `single` shape: the Default Organization exists + // before any user (the boot invariant), and the first-user promotion's + // grant is owned by it. sys_user_permission_set: [ - { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, ], sys_member: [], - sys_organization: [], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default', name: 'Default Organization' }], sys_user: [], ...seed, }; @@ -104,7 +129,7 @@ function makeQl(seed: Partial> = {}) { describe('ensureDefaultOrganization (plugin-auth home)', () => { it('creates the default org and binds the admin as owner', async () => { - const ql = makeQl(); + const ql = makeConfigOnlyQl(); const res = await ensureDefaultOrganization(ql); expect(res.defaultOrgCreated).toBe(true); expect(res.memberCreated).toBe(true); @@ -188,11 +213,45 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => { expect(ql.tables.sys_organization).toHaveLength(1); }); + it('[ADR-0131 D1] an organization-less admin grant binds nobody — even beside a Default Organization', async () => { + // The pre-18 row a store built before the ownership ceremony may still + // hold. The derivation reads it as no standing (`grantAnchorsPlatformStanding`), + // and this reader CONFERS — so binding its holder as the Default + // Organization's owner would make an owner nobody else calls an admin. + const ql = makeQl({ + sys_user_permission_set: [ + { id: 'ups_legacy', user_id: 'u_legacy', permission_set: 'admin_full_access', organization_id: null }, + ], + }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }); + expect(ql.insert).not.toHaveBeenCalled(); + // ⛔ The read itself names the Default Organization; no organization-less arm is asked. + const grantReads = ql.find.mock.calls.filter((c: any[]) => c[0] === 'sys_user_permission_set'); + expect(grantReads.map((c: any[]) => c[1]?.where?.organization_id)).toEqual([DEFAULT_ORG]); + }); + + it('[ADR-0131 D5] with no nameable Default Organization no grant is read, and nobody is bound', async () => { + // Several organizations, none `default`: nothing is the Default + // Organization, so no grant anchors (the resolver's rule) and the grant + // table is not asked at all. + const ql = makeQl({ + sys_organization: [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }], + sys_user_permission_set: [ + { id: 'ups_a', user_id: 'u1', permission_set: 'admin_full_access', organization_id: 'org_a' }, + { id: 'ups_legacy', user_id: 'u_legacy', permission_set: 'admin_full_access', organization_id: null }, + ], + }); + expect(await ensureDefaultOrganization(ql)).toMatchObject({ memberCreated: false, reason: 'no_admin' }); + expect(ql.find).not.toHaveBeenCalledWith('sys_user_permission_set', expect.anything(), expect.anything()); + expect(ql.insert).not.toHaveBeenCalled(); + }); + it('picks the OLDEST cross-tenant admin grant', async () => { const ql = makeQl({ sys_user_permission_set: [ - { id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-02T00:00:00Z' }, - { id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-01T00:00:00Z' }, + { id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG, created_at: '2026-01-02T00:00:00Z' }, + { id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG, created_at: '2026-01-01T00:00:00Z' }, ], }); await ensureDefaultOrganization(ql); @@ -227,7 +286,7 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => { // levels" puts that at `error`, and the two lines below used to be `warn`. describe('refused bootstrap writes report at `error` (#12981)', () => { function refusingQl(object: string) { - const ql = makeQl(); + const ql = makeConfigOnlyQl(); const realInsert = ql.insert; ql.insert = vi.fn(async (obj: string, data: Row) => { if (obj === object) throw new Error(`write refused: ${obj}`); @@ -434,8 +493,8 @@ describe('isDefaultOrganizationBootstrapTrigger', () => { * [#11663 L5] The legacy grant anchor is POSTURE-KEYED here too — the third * reader of the one anchor, keyed with the other two. * - * `resolve-authz-context.ts` §6b stopped deriving `PLATFORM_ADMIN` from an - * unscoped `admin_full_access` grant under a walled posture, and the break-glass + * `resolve-authz-context.ts` §6b stopped deriving `PLATFORM_ADMIN` from any + * `admin_full_access` grant under a walled posture, and the break-glass * guard's enumeration was keyed with it. This helper is the reader that does not * merely COUNT that population but CONFERS on it: whoever it picks is written a * `sys_member { role: 'owner' }` row on the Default Organization and handed the @@ -468,7 +527,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant anchors nothing', expect(res, walled).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }); expect(ql.insert, walled).not.toHaveBeenCalled(); expect(ql.tables.sys_member, walled).toHaveLength(0); - expect(ql.tables.sys_organization, walled).toHaveLength(0); + expect(ql.tables.sys_organization, walled).toEqual([expect.objectContaining({ id: DEFAULT_ORG })]); } }); @@ -561,7 +620,7 @@ describe('[#11663 L5] under a WALLED posture the legacy grant anchors nothing', describe('[ADR-0131 D3] ensureDefaultOrganizationExists — the boot invariant needs no admin', () => { it('creates the Default Organization on an empty install, with nobody to bind', async () => { - const ql = makeQl({ sys_user_permission_set: [] }); + const ql = makeQl({ sys_user_permission_set: [], sys_organization: [] }); const res = await ensureDefaultOrganizationExists(ql); expect(res.created).toBe(true); expect(ql.tables.sys_organization).toEqual([ @@ -586,7 +645,7 @@ describe('[ADR-0131 D3] ensureDefaultOrganizationExists — the boot invariant n }); it('a refused insert THROWS (the caller fails the boot) — it is not best-effort', async () => { - const ql = makeQl(); + const ql = makeQl({ sys_organization: [] }); ql.insert.mockImplementation(async () => { throw new Error('UNIQUE constraint failed: sys_organization.slug'); }); await expect(ensureDefaultOrganizationExists(ql)).rejects.toThrow( /BOOT REFUSED: the Default Organization could not be created[\s\S]*UNIQUE constraint failed/, @@ -622,18 +681,18 @@ describe('[ADR-0131 D3 / ADR-0093 D7] the first admin is the owner even when the ({ id: 'mem_reconciled', organization_id: organizationId, user_id: 'u1', role: 'member' }); it('promotes the reconciler-written `member` row in place and records the decision as `promoted`', async () => { - const ql = ledgerQl({ sys_organization: [{ id: 'org_d', slug: 'default' }], sys_member: [reconciled('org_d')] }); + const ql = ledgerQl({ sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_member: [reconciled(DEFAULT_ORG)] }); const res = await createEnsureDefaultOrganizationOnce()(ql); - expect(res).toMatchObject({ ownerPromoted: true, memberCreated: false, defaultOrgId: 'org_d' }); - expect(ql.tables.sys_member).toEqual([{ ...reconciled('org_d'), role: 'owner' }]); + expect(res).toMatchObject({ ownerPromoted: true, memberCreated: false, defaultOrgId: DEFAULT_ORG }); + expect(ql.tables.sys_member).toEqual([{ ...reconciled(DEFAULT_ORG), role: 'owner' }]); const decision = ql.tables.sys_migration.find((r: Row) => r.id === 'adr-0093-default-org-owner-bind'); - expect(JSON.parse(String(decision?.details))).toMatchObject({ outcome: 'promoted', organizationId: 'org_d' }); + expect(JSON.parse(String(decision?.details))).toMatchObject({ outcome: 'promoted', organizationId: DEFAULT_ORG }); }); it('never promotes once the owner bind is DECIDED (ADR-0093 D7: a removed owner stays removed)', async () => { const ql = ledgerQl({ - sys_organization: [{ id: 'org_d', slug: 'default' }], - sys_member: [reconciled('org_d')], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], + sys_member: [reconciled(DEFAULT_ORG)], sys_migration: [{ id: 'adr-0093-default-org-owner-bind', details: '{"outcome":"bound"}' }], }); const res = await createEnsureDefaultOrganizationOnce()(ql); @@ -644,21 +703,21 @@ describe('[ADR-0131 D3 / ADR-0093 D7] the first admin is the owner even when the it('never promotes over an existing owner, nor a role other than `member`', async () => { const withOwner = ledgerQl({ - sys_organization: [{ id: 'org_d', slug: 'default' }], - sys_member: [{ id: 'mem_o', organization_id: 'org_d', user_id: 'u9', role: 'owner' }, reconciled('org_d')], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], + sys_member: [{ id: 'mem_o', organization_id: DEFAULT_ORG, user_id: 'u9', role: 'owner' }, reconciled(DEFAULT_ORG)], }); await ensureDefaultOrganization(withOwner); expect(withOwner.update).not.toHaveBeenCalled(); const asAdmin = ledgerQl({ - sys_organization: [{ id: 'org_d', slug: 'default' }], - sys_member: [{ ...reconciled('org_d'), role: 'admin' }], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], + sys_member: [{ ...reconciled(DEFAULT_ORG), role: 'admin' }], }); expect(await ensureDefaultOrganization(asAdmin)).toMatchObject({ reason: 'admin_already_in_org' }); expect(asAdmin.update).not.toHaveBeenCalled(); }); it('a refused promotion reports at `error` and leaves the bind undecided for the next trigger', async () => { - const ql = ledgerQl({ sys_organization: [{ id: 'org_d', slug: 'default' }], sys_member: [reconciled('org_d')] }); + const ql = ledgerQl({ sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_member: [reconciled(DEFAULT_ORG)] }); ql.update.mockImplementation(async () => { throw new Error('write refused'); }); const error = vi.fn(); const res = await createEnsureDefaultOrganizationOnce({ logger: { info: vi.fn(), warn: vi.fn(), error } as any })(ql); From cf9e2ae24cc00494e245263174ef46a9f96eeebe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:42:14 +0000 Subject: [PATCH 4/7] test(plugin-auth): pin the guard's anchor parity with the resolver, the lean-embedding fail-open shut Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- packages/plugins/plugin-auth/package.json | 1 + .../last-admin-guard.anchor-parity.test.ts | 293 ++++++++++++++++++ pnpm-lock.yaml | 3 + 3 files changed, 297 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index f44652d3895..f539f3b0e15 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -46,6 +46,7 @@ "jose": "^6.2.12" }, "devDependencies": { + "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/formula": "workspace:*", "@objectstack/objectql": "workspace:*", diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts new file mode 100644 index 00000000000..f3bac47f939 --- /dev/null +++ b/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D1/D5] The guard and the resolver read ONE anchor, in every + * composition — the parity the guard's header declares ("the enumeration + * answers the SAME question the derivation does"), measured against the + * resolver itself rather than restated. + * + * `resolveAuthzContext` gives an `admin_full_access` grant platform standing + * only under `single`, and only when the Default Organization owns it + * (`grantAnchorsPlatformStanding`). An organization-less grant anchors nothing, + * and a composition that registers no organization object has no Default + * Organization, so no grant anchors there. A guard that still counted the + * organization-less row believed an administrator remained where none did, and + * let a write take the deployment's last platform administrator — the + * fail-open this file pins shut. + * + * Every case runs a REAL {@link ObjectQL} engine over a real driver + * (better-sqlite3 `:memory:`, or driver-memory), registers the real guard, and + * asks the real resolver. Three things are pinned: + * + * 1. **Where the row can exist.** Through the engine, an organization-less + * grant is derived or refused wherever the organization object is + * registered (ADR-0131 D9), and refused by the NOT NULL column a fresh SQL + * table carries (D1) where it is not. Two compositions can still hold one: + * a lean embedding on driver-memory, and a store built before 18.x. + * 2. **The negative pin** — in both, with a verified `OS_PLATFORM_OWNER_EMAIL` + * account as the resolver's only platform administrator and an + * organization-less grant holder beside it, banning that administrator is + * REFUSED. + * 3. **The positive control** — the Default Organization's grant still + * anchors: its holder is the resolver's administrator, and the guard + * protects them. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { resetPlatformAdminEmailMemo, resolveAuthzContext } from '@objectstack/core'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; + +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; + +const SYSTEM = { context: { isSystem: true } } as const; +const OWNER = 'owner@corp.example'; +const DEFAULT_ORG = 'org_default'; +const ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; + +const text = (name: string) => ({ name, type: 'text' as const }); +const sysUser = { + 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 }, + }, +}; +const sysMember = { + 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') }, +}; +const sysOrganization = { + name: 'sys_organization', + label: 'Organization', + managedBy: 'better-auth', + fields: { id: { ...text('id'), primaryKey: true }, name: text('name'), slug: text('slug') }, +}; +const sysUserPermissionSet = { + 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 }, + }, +}; +/** Read by the resolver alongside the grants; no case seeds it. */ +const sysUserPosition = { + name: 'sys_user_position', + label: 'User Position', + fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), position: text('position'), organization_id: text('organization_id') }, +}; + +type DriverKind = 'sqlite' | 'memory'; +interface Rig { + engine: ObjectQL; + /** The driver itself, for the rows only a store built before 18.x holds. */ + driver: { create(object: string, row: Record): Promise }; +} + +let engines: ObjectQL[] = []; +let ambient: Record = {}; + +beforeEach(() => { + ambient = Object.fromEntries(ENV.map((k) => [k, process.env[k]])); + for (const k of ENV) delete process.env[k]; + resetPlatformAdminEmailMemo(); +}); + +afterEach(async () => { + for (const [k, v] of Object.entries(ambient)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + resetPlatformAdminEmailMemo(); + const open = engines; + engines = []; + for (const e of open) { + try { await e.destroy(); } catch { /* noop */ } + } +}); + +async function boot(kind: DriverKind, opts: { organizationObject: boolean; legacyStore?: boolean }): Promise { + const engine = new ObjectQL(); + engines.push(engine); + const driver = kind === 'sqlite' + ? new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) + : new InMemoryDriver(); + engine.registerDriver(driver as never, true); + await engine.init(); + const objects = [sysUser, sysMember, sysUserPermissionSet, sysUserPosition, ...(opts.organizationObject ? [sysOrganization] : [])]; + for (const o of objects) engine.registry.registerObject(o as never); + await engine.syncSchemas(); + // A store built before 18.x: the tenant column nullable (ADR-0131 D1). + if (opts.legacyStore) await relaxEveryRegisteredTenantColumn(engine); + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard-parity' }); + return { engine, driver: driver as unknown as Rig['driver'] }; +} + +async function seedUser(engine: ObjectQL, id: string, email: string): Promise { + await engine.insert('sys_user', { id, name: id, email, email_verified: true, banned: false }, SYSTEM); +} + +/** An organization-less `admin_full_access` grant, through the engine. */ +const insertOrganizationLessGrant = (engine: ObjectQL, userId: string) => + engine.insert('sys_user_permission_set', { id: `ups_${userId}`, user_id: userId, permission_set: ADMIN_FULL_ACCESS }, SYSTEM); + +/** Does the resolver grade `userId` a platform administrator? */ +async function resolverStanding(engine: ObjectQL, userId: string, email?: string): Promise { + const ctx = await resolveAuthzContext({ + ql: engine, + headers: {}, + getSession: () => ({ user: { id: userId, email }, session: { userId } }), + } as never); + return ctx.positions.includes('platform_admin'); +} + +const ban = (engine: ObjectQL, id: string) => engine.update('sys_user', { id, banned: true }, SYSTEM); + +/** The refusal envelope, or `undefined` when the write landed. */ +async function refusalOf(write: Promise): Promise<{ code?: unknown; status?: unknown } | undefined> { + try { + await write; + return undefined; + } catch (err) { + const e = err as { code?: unknown; status?: unknown }; + return { code: e.code, status: e.status }; + } +} + +describe('[ADR-0131 D1/D9] where an organization-less admin_full_access grant can still be written', () => { + type Expect = 'derived' | 'refused-d9' | 'refused-not-null' | 'stored-null'; + const cases: Array<{ kind: DriverKind; organizations: number | 'no-object'; posture: 'single' | 'isolated'; expected: Expect }> = [ + { kind: 'sqlite', organizations: 'no-object', posture: 'single', expected: 'refused-not-null' }, + { kind: 'sqlite', organizations: 0, posture: 'single', expected: 'refused-d9' }, + { kind: 'sqlite', organizations: 1, posture: 'single', expected: 'derived' }, + { kind: 'sqlite', organizations: 2, posture: 'single', expected: 'refused-d9' }, + { kind: 'sqlite', organizations: 0, posture: 'isolated', expected: 'refused-d9' }, + { kind: 'sqlite', organizations: 1, posture: 'isolated', expected: 'refused-d9' }, + { kind: 'sqlite', organizations: 2, posture: 'isolated', expected: 'refused-d9' }, + { kind: 'memory', organizations: 'no-object', posture: 'single', expected: 'stored-null' }, + { kind: 'memory', organizations: 0, posture: 'single', expected: 'refused-d9' }, + ]; + + for (const c of cases) { + it(`${c.kind}, ${c.organizations === 'no-object' ? 'no organization object' : `${c.organizations} organization(s)`}, ${c.posture}: ${c.expected} — and the guard agrees with the resolver about its holder`, async () => { + process.env.OS_TENANCY_POSTURE = c.posture; + const { engine, driver } = await boot(c.kind, { organizationObject: c.organizations !== 'no-object' }); + const organizations = typeof c.organizations === 'number' ? c.organizations : 0; + // Written beneath the engine, before anything has asked it about + // organizations: its probe reads the table at the grant insert below. + if (organizations >= 1) await driver.create('sys_organization', { id: DEFAULT_ORG, name: 'Default', slug: 'default' }); + if (organizations >= 2) await driver.create('sys_organization', { id: 'org_b', name: 'B', slug: 'b' }); + await seedUser(engine, 'usr_holder', 'holder@corp.example'); + + const refusal = await refusalOf(insertOrganizationLessGrant(engine, 'usr_holder')); + const rows = (await engine.find('sys_user_permission_set', { where: {} }, SYSTEM)) as Array>; + if (c.expected === 'refused-d9') { + expect(refusal?.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(rows).toEqual([]); + } else if (c.expected === 'refused-not-null') { + expect(refusal?.code).toBe('SQLITE_CONSTRAINT_NOTNULL'); + expect(rows).toEqual([]); + } else if (c.expected === 'derived') { + expect(refusal).toBeUndefined(); + expect(rows.map((r) => r.organization_id)).toEqual([DEFAULT_ORG]); + } else { + expect(refusal).toBeUndefined(); + expect(rows.map((r) => r.organization_id ?? null)).toEqual([null]); + } + + // Parity: the holder is the only candidate, so the guard refuses to ban + // them exactly when it counts them — and it must count them exactly when + // the resolver grades them a platform administrator. + const isAdmin = await resolverStanding(engine, 'usr_holder'); + expect(isAdmin).toBe(c.expected === 'derived' && c.posture === 'single'); + const banRefusal = await refusalOf(ban(engine, 'usr_holder')); + expect(banRefusal?.code).toBe(isAdmin ? 'PERMISSION_DENIED' : undefined); + }); + } + + it('driver-memory refuses a walled posture outright — no walled composition holds the row there', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + await expect(boot('memory', { organizationObject: true })).rejects.toMatchObject({ code: 'MEMORY_MULTI_TENANT_UNSUPPORTED' }); + }); +}); + +describe('[ADR-0131 D5] the negative pin: an organization-less holder is no survivor', () => { + /** The resolver's only platform administrator (config) beside an organization-less grant holder. */ + async function seedTwoCandidates(rig: Rig, viaDriver: boolean): Promise { + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER; + resetPlatformAdminEmailMemo(); + await seedUser(rig.engine, 'usr_owner', OWNER); + await seedUser(rig.engine, 'usr_legacy', 'legacy@corp.example'); + if (viaDriver) { + await rig.driver.create('sys_user_permission_set', { id: 'ups_legacy', user_id: 'usr_legacy', permission_set: ADMIN_FULL_ACCESS }); + } else { + await insertOrganizationLessGrant(rig.engine, 'usr_legacy'); + } + expect(await resolverStanding(rig.engine, 'usr_owner', OWNER), 'the owner is the resolver\'s administrator').toBe(true); + expect(await resolverStanding(rig.engine, 'usr_legacy'), 'the holder is no administrator to the resolver').toBe(false); + } + + it('⭐ a lean embedding on driver-memory (no sys_organization): banning the only platform administrator is REFUSED', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + const rig = await boot('memory', { organizationObject: false }); + await seedTwoCandidates(rig, false); + + await expect(ban(rig.engine, 'usr_owner')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + const row = await rig.engine.findOne('sys_user', { where: { id: 'usr_owner' }, fields: ['id', 'banned'] }, SYSTEM); + expect(row?.banned).toBeFalsy(); + }); + + it('⭐ a store built before 18.x (sys_organization registered, the row beneath the engine): the same ban is REFUSED', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + const rig = await boot('sqlite', { organizationObject: true, legacyStore: true }); + await rig.driver.create('sys_organization', { id: DEFAULT_ORG, name: 'Default', slug: 'default' }); + await seedTwoCandidates(rig, true); + + await expect(ban(rig.engine, 'usr_owner')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + const row = await rig.engine.findOne('sys_user', { where: { id: 'usr_owner' }, fields: ['id', 'banned'] }, SYSTEM); + expect(row?.banned).toBeFalsy(); + }); +}); + +describe('[ADR-0131 D5] the positive control: the Default Organization\'s grant still anchors', () => { + it('its holder is the resolver\'s administrator and the guard protects them; a second administrator makes the write legal', async () => { + process.env.OS_TENANCY_POSTURE = 'single'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER; + resetPlatformAdminEmailMemo(); + const { engine } = await boot('sqlite', { organizationObject: true }); + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); + await seedUser(engine, 'usr_promoted', 'promoted@corp.example'); + await engine.insert( + 'sys_user_permission_set', + { id: 'ups_promoted', user_id: 'usr_promoted', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }, + SYSTEM, + ); + expect(await resolverStanding(engine, 'usr_promoted')).toBe(true); + + // The only administrator: refused. + await expect(ban(engine, 'usr_promoted')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + + // With the config owner beside them, each is a survivor for the other. + await seedUser(engine, 'usr_owner', OWNER); + expect(await resolverStanding(engine, 'usr_owner', OWNER)).toBe(true); + await expect(ban(engine, 'usr_owner')).resolves.toBeDefined(); + await expect(ban(engine, 'usr_promoted')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9edeba45548..8b95f3825d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1731,6 +1731,9 @@ importers: specifier: ^6.2.12 version: 6.2.12 devDependencies: + '@objectstack/driver-memory': + specifier: workspace:* + version: link:../../drivers/driver-memory '@objectstack/driver-sql': specifier: workspace:* version: link:../../drivers/driver-sql From 433c9462179a62c1ec0415c99247cf0ea345f345 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:44:14 +0000 Subject: [PATCH 5/7] docs(plugin-auth): the guard's anchor and the embedder's remedy; the changeset Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- ...admin-guard-default-organization-anchor.md | 22 +++++++++++++++++++ content/docs/permissions/authorization.mdx | 19 +++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 .changeset/24883-last-admin-guard-default-organization-anchor.md diff --git a/.changeset/24883-last-admin-guard-default-organization-anchor.md b/.changeset/24883-last-admin-guard-default-organization-anchor.md new file mode 100644 index 00000000000..0d66a41113f --- /dev/null +++ b/.changeset/24883-last-admin-guard-default-organization-anchor.md @@ -0,0 +1,22 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way. + +- **`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before. + - The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded. + - It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator. + - Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there). +- **`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before. + +**Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified. + +**What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 921324282bf..e3de008add8 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -133,9 +133,10 @@ implementation detail: deployment's **configured administrator list** — an address declared in `OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and **email-verified** `sys_user` row — is sufficient under every posture. An - **unscoped `admin_full_access` grant** row is sufficient only under - `single`; under the walled postures (`group` / `isolated`) it is no longer - an anchor — the set it names still grants what any held set grants, but it + **`admin_full_access` grant owned by the Default Organization** is + sufficient only under `single`; a grant owned by no organization anchors + nothing in any posture, and under the walled postures (`group` / + `isolated`) no grant is an anchor — the set it names still grants what any held set grants, but it confers no `PLATFORM_ADMIN` standing, so there standing comes from configuration alone. See [who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets) @@ -361,7 +362,7 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`, the explain engine, sharing-rule position expansion, and (transitively) the delegated-admin gate's held-scope resolution. No background cleanup job is involved (ADR-0049); the clock is checked on every resolution. An expired -unscoped `admin_full_access` grant no longer derives `platform_admin` — a +`admin_full_access` grant no longer derives `platform_admin` — a statement about the *grant* anchor. The configured-administrator anchor is not a stored grant row at all, so it carries no validity window and nothing here dates it; it is revoked by changing the configuration. @@ -450,7 +451,7 @@ high-privilege gate runs on the definition each time it is derived. See - A deactivated **permission set** contributes nothing: not its name, not its `system_permissions`, not its `tab_permissions`. As with an expired grant, a - deactivated unscoped `admin_full_access` no longer derives `platform_admin` + deactivated `admin_full_access` no longer derives `platform_admin` — the flag is applied *before* the posture is derived, not after. A **configured** administrator is judged elsewhere: that anchor reads the deployment's configuration and the caller's own `sys_user` row, never the @@ -480,6 +481,14 @@ grant away; a permission set or position with no ledger row is in effect. The last-administrator guard reads the same two places the resolver does: whether the catalogue holds `admin_full_access`, and whether the ledger switches it off. It reads no catalogue row, so a row's `active` changes nothing it counts. +It counts the same anchors the resolver derives `platform_admin` from — under +`single`, the Default Organization's `admin_full_access` grant; under every +posture, the configured administrator list — beside organization owners and +admins. A grant owned by no organization counts nowhere. So a host that +registers the guard (`registerLastAdminGuard` from `@objectstack/plugin-auth`) +on an engine with no organization object has no grant-anchored administrator +at all: its administrators are the accounts declared in +`OS_PLATFORM_OWNER_EMAIL` and the organization owners and admins it holds. **Switching the break-glass set off is refused while it would leave no administrator.** A ledger row switching `admin_full_access` off un-makes every From 980cdc13ce8fe6192236205bb2f5a358b16a77ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:07:42 +0000 Subject: [PATCH 6/7] test(plugin-auth): the lean-embedding pin runs on a nullable sqlite column, not driver-memory The driver-memory census refuses a new consumer without a maintainer ruling. Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- packages/plugins/plugin-auth/package.json | 1 - .../last-admin-guard.anchor-parity.test.ts | 75 ++++++++++--------- pnpm-lock.yaml | 3 - 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index f539f3b0e15..f44652d3895 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -46,7 +46,6 @@ "jose": "^6.2.12" }, "devDependencies": { - "@objectstack/driver-memory": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/formula": "workspace:*", "@objectstack/objectql": "workspace:*", diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts index f3bac47f939..2d3cb950549 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.anchor-parity.test.ts @@ -15,16 +15,19 @@ * let a write take the deployment's last platform administrator — the * fail-open this file pins shut. * - * Every case runs a REAL {@link ObjectQL} engine over a real driver - * (better-sqlite3 `:memory:`, or driver-memory), registers the real guard, and - * asks the real resolver. Three things are pinned: + * Every case runs a REAL {@link ObjectQL} engine over better-sqlite3 + * `:memory:`, registers the real guard, and asks the real resolver. Three + * things are pinned: * * 1. **Where the row can exist.** Through the engine, an organization-less * grant is derived or refused wherever the organization object is * registered (ADR-0131 D9), and refused by the NOT NULL column a fresh SQL - * table carries (D1) where it is not. Two compositions can still hold one: - * a lean embedding on driver-memory, and a store built before 18.x. - * 2. **The negative pin** — in both, with a verified `OS_PLATFORM_OWNER_EMAIL` + * table carries (D1) where it is not. What can still hold one is a column + * that is NOT NULL nowhere: a store built before 18.x, or a driver that + * enforces no NOT NULL (driver-memory, measured on #24883), in a lean + * embedding that registers no organization object or beneath the engine. + * The nullable column stands in for both here. + * 2. **The negative pin** — in both shapes, with a verified `OS_PLATFORM_OWNER_EMAIL` * account as the resolver's only platform administrator and an * organization-less grant holder beside it, banning that administrator is * REFUSED. @@ -36,7 +39,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { resetPlatformAdminEmailMemo, resolveAuthzContext } from '@objectstack/core'; import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; @@ -93,7 +95,6 @@ const sysUserPosition = { fields: { id: { ...text('id'), primaryKey: true }, user_id: text('user_id'), position: text('position'), organization_id: text('organization_id') }, }; -type DriverKind = 'sqlite' | 'memory'; interface Rig { engine: ObjectQL; /** The driver itself, for the rows only a store built before 18.x holds. */ @@ -122,19 +123,19 @@ afterEach(async () => { } }); -async function boot(kind: DriverKind, opts: { organizationObject: boolean; legacyStore?: boolean }): Promise { +async function boot(opts: { organizationObject: boolean; nullableTenantColumn?: boolean }): Promise { const engine = new ObjectQL(); engines.push(engine); - const driver = kind === 'sqlite' - ? new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) - : new InMemoryDriver(); - engine.registerDriver(driver as never, true); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + engine.registerDriver(driver, true); await engine.init(); const objects = [sysUser, sysMember, sysUserPermissionSet, sysUserPosition, ...(opts.organizationObject ? [sysOrganization] : [])]; for (const o of objects) engine.registry.registerObject(o as never); await engine.syncSchemas(); - // A store built before 18.x: the tenant column nullable (ADR-0131 D1). - if (opts.legacyStore) await relaxEveryRegisteredTenantColumn(engine); + // A tenant column that is NOT NULL nowhere: a store built before 18.x has one + // (ADR-0131 D1 makes a fresh one NOT NULL), and so does a driver that + // enforces no NOT NULL. + if (opts.nullableTenantColumn) await relaxEveryRegisteredTenantColumn(engine); bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard-parity' }); return { engine, driver: driver as unknown as Rig['driver'] }; @@ -173,22 +174,29 @@ async function refusalOf(write: Promise): Promise<{ code?: unknown; sta describe('[ADR-0131 D1/D9] where an organization-less admin_full_access grant can still be written', () => { type Expect = 'derived' | 'refused-d9' | 'refused-not-null' | 'stored-null'; - const cases: Array<{ kind: DriverKind; organizations: number | 'no-object'; posture: 'single' | 'isolated'; expected: Expect }> = [ - { kind: 'sqlite', organizations: 'no-object', posture: 'single', expected: 'refused-not-null' }, - { kind: 'sqlite', organizations: 0, posture: 'single', expected: 'refused-d9' }, - { kind: 'sqlite', organizations: 1, posture: 'single', expected: 'derived' }, - { kind: 'sqlite', organizations: 2, posture: 'single', expected: 'refused-d9' }, - { kind: 'sqlite', organizations: 0, posture: 'isolated', expected: 'refused-d9' }, - { kind: 'sqlite', organizations: 1, posture: 'isolated', expected: 'refused-d9' }, - { kind: 'sqlite', organizations: 2, posture: 'isolated', expected: 'refused-d9' }, - { kind: 'memory', organizations: 'no-object', posture: 'single', expected: 'stored-null' }, - { kind: 'memory', organizations: 0, posture: 'single', expected: 'refused-d9' }, + type Column = 'fresh' | 'nullable'; + const cases: Array<{ column: Column; organizations: number | 'no-object'; posture: 'single' | 'isolated'; expected: Expect }> = [ + { column: 'fresh', organizations: 'no-object', posture: 'single', expected: 'refused-not-null' }, + { column: 'fresh', organizations: 0, posture: 'single', expected: 'refused-d9' }, + { column: 'fresh', organizations: 1, posture: 'single', expected: 'derived' }, + { column: 'fresh', organizations: 2, posture: 'single', expected: 'refused-d9' }, + { column: 'fresh', organizations: 0, posture: 'isolated', expected: 'refused-d9' }, + { column: 'fresh', organizations: 1, posture: 'isolated', expected: 'refused-d9' }, + { column: 'fresh', organizations: 2, posture: 'isolated', expected: 'refused-d9' }, + // The engine's no-organization-object exit (ADR-0131 D9) leaves the row + // organization-less; only the column could refuse it. + { column: 'nullable', organizations: 'no-object', posture: 'single', expected: 'stored-null' }, + { column: 'nullable', organizations: 1, posture: 'single', expected: 'derived' }, + { column: 'nullable', organizations: 1, posture: 'isolated', expected: 'refused-d9' }, ]; for (const c of cases) { - it(`${c.kind}, ${c.organizations === 'no-object' ? 'no organization object' : `${c.organizations} organization(s)`}, ${c.posture}: ${c.expected} — and the guard agrees with the resolver about its holder`, async () => { + it(`${c.column} tenant column, ${c.organizations === 'no-object' ? 'no organization object' : `${c.organizations} organization(s)`}, ${c.posture}: ${c.expected} — and the guard agrees with the resolver about its holder`, async () => { process.env.OS_TENANCY_POSTURE = c.posture; - const { engine, driver } = await boot(c.kind, { organizationObject: c.organizations !== 'no-object' }); + const { engine, driver } = await boot({ + organizationObject: c.organizations !== 'no-object', + nullableTenantColumn: c.column === 'nullable', + }); const organizations = typeof c.organizations === 'number' ? c.organizations : 0; // Written beneath the engine, before anything has asked it about // organizations: its probe reads the table at the grant insert below. @@ -221,11 +229,6 @@ describe('[ADR-0131 D1/D9] where an organization-less admin_full_access grant ca expect(banRefusal?.code).toBe(isAdmin ? 'PERMISSION_DENIED' : undefined); }); } - - it('driver-memory refuses a walled posture outright — no walled composition holds the row there', async () => { - process.env.OS_TENANCY_POSTURE = 'isolated'; - await expect(boot('memory', { organizationObject: true })).rejects.toMatchObject({ code: 'MEMORY_MULTI_TENANT_UNSUPPORTED' }); - }); }); describe('[ADR-0131 D5] the negative pin: an organization-less holder is no survivor', () => { @@ -244,9 +247,9 @@ describe('[ADR-0131 D5] the negative pin: an organization-less holder is no surv expect(await resolverStanding(rig.engine, 'usr_legacy'), 'the holder is no administrator to the resolver').toBe(false); } - it('⭐ a lean embedding on driver-memory (no sys_organization): banning the only platform administrator is REFUSED', async () => { + it('⭐ a lean embedding (no sys_organization) whose tenant column is NOT NULL nowhere: banning the only platform administrator is REFUSED', async () => { process.env.OS_TENANCY_POSTURE = 'single'; - const rig = await boot('memory', { organizationObject: false }); + const rig = await boot({ organizationObject: false, nullableTenantColumn: true }); await seedTwoCandidates(rig, false); await expect(ban(rig.engine, 'usr_owner')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); @@ -256,7 +259,7 @@ describe('[ADR-0131 D5] the negative pin: an organization-less holder is no surv it('⭐ a store built before 18.x (sys_organization registered, the row beneath the engine): the same ban is REFUSED', async () => { process.env.OS_TENANCY_POSTURE = 'single'; - const rig = await boot('sqlite', { organizationObject: true, legacyStore: true }); + const rig = await boot({ organizationObject: true, nullableTenantColumn: true }); await rig.driver.create('sys_organization', { id: DEFAULT_ORG, name: 'Default', slug: 'default' }); await seedTwoCandidates(rig, true); @@ -271,7 +274,7 @@ describe('[ADR-0131 D5] the positive control: the Default Organization\'s grant process.env.OS_TENANCY_POSTURE = 'single'; process.env.OS_PLATFORM_OWNER_EMAIL = OWNER; resetPlatformAdminEmailMemo(); - const { engine } = await boot('sqlite', { organizationObject: true }); + const { engine } = await boot({ organizationObject: true }); await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default Organization', slug: 'default' }, SYSTEM); await seedUser(engine, 'usr_promoted', 'promoted@corp.example'); await engine.insert( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b95f3825d3..9edeba45548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1731,9 +1731,6 @@ importers: specifier: ^6.2.12 version: 6.2.12 devDependencies: - '@objectstack/driver-memory': - specifier: workspace:* - version: link:../../drivers/driver-memory '@objectstack/driver-sql': specifier: workspace:* version: link:../../drivers/driver-sql From bc359a7c731f390b322a3958ffd0d2a259308dec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:22:59 +0000 Subject: [PATCH 7/7] docs(permissions): the platform-admin grant anchor is the Default Organization's; an organization-less grant anchors nothing Claude-Session: https://claude.ai/code/session_01GvYt2WyJja4rEjcy6pdD4p Co-authored-by: Claude --- content/docs/permissions/permission-sets.mdx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index a0a2e189b8f..e780f28778a 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -187,15 +187,17 @@ sufficient depends on the deployment's are one administrator, not two half-matches. Nothing is stored and nothing is granted — the answer is recomputed from configuration on every resolution. This anchor is read under every posture. -- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set` - row with `organization_id = NULL`, the original anchor. It confers standing - under the `single` posture only. Under the walled postures (`group` / - `isolated`) the row is no longer an anchor: the set it names still grants - what any held set grants, but it confers no `PLATFORM_ADMIN` standing, and - nothing is logged about it per request. If a walled deployment holding such a - row ends up with no usable configured administrator, the boot log says it has - zero platform administrators, names the account that holds the row, and points - at `OS_PLATFORM_OWNER_EMAIL` as the way to restore that account's standing. +- **The Default Organization's `admin_full_access` grant row** — a + `sys_user_permission_set` row owned by the Default Organization, the row the + `single` first-user promotion writes. It confers standing under the `single` + posture only. An organization-less grant (`organization_id = NULL`, the row an + earlier release's promotion wrote, which the ownership ceremony attributes to + an organization) anchors nothing, in any posture. Under the walled postures + (`group` / `isolated`) no grant row is an anchor: the set it names still + grants what any held set grants, but it confers no `PLATFORM_ADMIN` standing, + and nothing is logged about it per request. A walled deployment that declares + no usable configured administrator is told so at boot: the log says it has + zero platform administrators and points at `OS_PLATFORM_OWNER_EMAIL`. Which anchor a fresh deployment gets depends on its [tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first