From fb8f2b3db1ac2058805960ff5a61583eb0ca80f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:44:48 +0000 Subject: [PATCH 1/5] fix(plugin-auth)!: under single, no slug change makes another organization the Default Organization Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). Re-slugging another organization to `default` made it the Default Organization: measured on 8dfd1265f0, better-auth `organization/update` answered 200 and the grant holder became platform_admin through the real resolveAuthzContext; a system-context engine update did the same. One judge, `refuseDefaultOrganizationSlugCapture`, refuses it unless the organization already is the Default Organization. The last-administrator guard runs it on `sys_organization` `beforeUpdate` (every engine write), and `beforeUpdateOrganization` asks it before the vendor writes so the HTTP door answers 403 PERMISSION_DENIED instead of a 500. Walled postures read nothing and admit. Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude --- .../25271-single-reslug-default-anchor.md | 18 ++ .../plugins/plugin-auth/src/auth-manager.ts | 30 ++ .../plugin-auth/src/last-admin-guard.ts | 92 +++++- .../src/single-reslug-default-anchor.test.ts | 275 ++++++++++++++++++ 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 .changeset/25271-single-reslug-default-anchor.md create mode 100644 packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts diff --git a/.changeset/25271-single-reslug-default-anchor.md b/.changeset/25271-single-reslug-default-anchor.md new file mode 100644 index 00000000000..842e9fe2861 --- /dev/null +++ b/.changeset/25271-single-reslug-default-anchor.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth)!: under `single`, no slug change makes another organization the Default Organization — re-slugging to `default` captured the platform-admin anchor + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** for a `single` deployment that holds several organizations, none slugged `default`, and re-slugs one of them to `default`. Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). A re-slug to `default` therefore made that organization the Default Organization, and every holder of a grant it owned a platform administrator: an organization owner could do it through better-auth's `POST /auth/organization/update`, and a seed or a plugin through an engine update. + +- **FROM:** a slug changed to `default` on any organization was admitted, as long as the last-administrator guard found an administrator left standing. **TO:** under `single` it is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Any other organization is refused `403 PERMISSION_DENIED`, on every door: better-auth's `organization/update` answers it before the vendor writes, and the last-administrator guard's `sys_organization` `beforeUpdate` hook refuses it for every engine write, `isSystem` included. A read that cannot answer refuses too: `503 SERVICE_UNAVAILABLE` at the HTTP door, `403 PERMISSION_DENIED` at the engine. +- **Unchanged:** a slug changed to any other value; every slug change under a walled posture (`isolated`, `group`), where no grant anchors and a slug is just a slug; the data API, whose user-context PATCH and update-mode import were already refused a `slug` by the ADR-0092 identity write guard. +- **New export:** `refuseDefaultOrganizationSlugCapture(engine, organizationIds, slug)`, the one judge both doors call. + +**What an operator does.** Keep another slug. To name a Default Organization on a `single` deployment that holds several organizations, hold one organization (merge the others into it; the sole organization is the Default Organization and may then take the slug `default`), or declare a walled posture (`OS_TENANCY_POSTURE=group` or `isolated`), as the tenancy organization census already prescribes. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index c3dd34d7bf3..c9d53cffdb1 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -158,6 +158,7 @@ import type { TenancyService } from './tenancy-service.js'; import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js'; import type { CounterStore } from './rate-limit-storage.js'; import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; +import { refuseDefaultOrganizationSlugCapture } from './last-admin-guard.js'; import { isLastLocalCredentialHolder, LAST_LOCAL_CREDENTIAL_CODE, @@ -3882,6 +3883,35 @@ export class AuthManager { // whenever either read faulted. const rawEngine = this.config.dataEngine; if (!rawEngine) return; + + // [ADR-0131 D5] Under `single` a slug changed TO `default` makes the + // organization the Default Organization, whose `admin_full_access` + // grant confers platform administrator standing, so only the + // Default Organization may take it. The last-administrator guard's + // engine hook refuses that write on every door; asked here first, + // before the vendor writes, the refusal answers 403 with its code + // rather than the 500 better-call gives a throw from inside the + // adapter. Its reads run as the platform; one that throws is a 503, + // like this guard's own. + try { + await refuseDefaultOrganizationSlugCapture(rawEngine as never, new Set([String(orgId)]), newSlug); + } catch (err) { + const { APIError } = await import('better-auth/api'); + if ((err as { code?: unknown } | null)?.code === 'PERMISSION_DENIED') { + throw new APIError('FORBIDDEN', { message: (err as Error).message, code: 'PERMISSION_DENIED' }); + } + const fault = new APIError('SERVICE_UNAVAILABLE', { + message: + "The organization slug guard could not read `sys_organization`, so whether the slug 'default' " + + 'would make this organization the Default Organization was never determined, and the slug was ' + + 'not changed. This is a server-side outage, not a refusal of the new slug: retry once the data ' + + 'store is reachable.', + code: SLUG_GUARD_READ_FAULT_CODE, + }); + (fault as { cause?: unknown }).cause = err; + throw fault; + } + const schemaOf = (rawEngine as { getSchema?: (object: string) => unknown }).getSchema; if (typeof schemaOf === 'function' && !schemaOf.call(rawEngine, 'sys_environment')) return; diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 5aef66fb5c3..b3ffedf8deb 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -325,6 +325,7 @@ import { postureEnforcesWall } from '@objectstack/spec/security'; import { SystemObjectName, SystemUserId } from '@objectstack/spec/system'; import { isMissingTableError, resolveTenancyPosture } from '@objectstack/types'; import { + DEFAULT_ORGANIZATION_SLUG, findDefaultOrganization, isGrantActive, matchesConfiguredPlatformAdmin, @@ -923,6 +924,60 @@ function touchesAny(data: Record, keys: readonly string[]): boo return keys.some((k) => k in data); } +/** + * [ADR-0131 D5] Under `single` the Default Organization is the organization + * slugged `default`, else the only one, and its `admin_full_access` grant + * confers platform administrator standing. So re-slugging ANOTHER organization + * to `default` makes it the Default Organization: on a store holding several + * organizations, none slugged `default`, an organization owner who re-slugs + * their own takes the anchor, and every holder of a grant it owns becomes a + * platform administrator. The break-glass halves never see that, because it + * takes standing from nobody. + * + * This refuses it: a slug changed TO `default` is admitted only on the + * organization that is already the Default Organization (the sole one, + * re-slugged to the name the boot gives it). Under a wall no grant anchors and + * a slug is just a slug, so nothing is read. A slug change to any other value + * is not this rule's (the update half judges moving the anchor AWAY). + * + * One judge, two callers: the guard's `beforeUpdate` hook, which every engine + * write reaches (better-auth's adapter, a seed, a plugin, the data API), and + * better-auth's `beforeUpdateOrganization`, which asks it before the vendor + * writes so the HTTP door answers 403 with the code instead of the 500 a + * refusal from inside the adapter would become. A read that throws + * propagates: each caller's read policy decides what an unreadable store means. + */ +export async function refuseDefaultOrganizationSlugCapture( + engine: Pick, + organizationIds: ReadonlySet, + slug: unknown, +): Promise { + if (slug !== DEFAULT_ORGANIZATION_SLUG || organizationIds.size === 0) return; + const posture = resolveTenancyPosture(); + if (postureEnforcesWall(posture)) return; + const { id, several } = await findDefaultOrganization((where, limit) => + engine.find(SystemObjectName.ORGANIZATION, { where: { ...where }, limit, fields: ['id'] }, SYSTEM_READ), + ); + if (id !== undefined && organizationIds.size === 1 && organizationIds.has(id)) return; + const named = [...organizationIds].map((o) => `'${o}'`).join(', '); + const standing = + id !== undefined + ? `This deployment's Default Organization is '${id}'.` + : several + ? 'This deployment holds several organizations and none of them is the Default Organization.' + : 'This deployment names no Default Organization.'; + throw refuse( + `Refusing this organization update: under the '${posture}' tenancy posture the slug ` + + `'${DEFAULT_ORGANIZATION_SLUG}' names the Default Organization, whose '${ADMIN_FULL_ACCESS}' grant ` + + 'confers platform administrator standing (ADR-0131 D5), so only the Default Organization may take ' + + `it, and re-slugging ${named} to it would make that organization the Default Organization. ` + + `${standing} Keep another slug. To name a Default Organization, hold one organization (the sole ` + + `organization is the Default Organization and may then take the slug '${DEFAULT_ORGANIZATION_SLUG}'), ` + + 'or declare a walled tenancy posture (OS_TENANCY_POSTURE=group or isolated), where a slug anchors nothing.', + SystemObjectName.ORGANIZATION, + ); +} + /** * Register the last-administrator guard on an ObjectQL engine: the ban half * (`beforeUpdate`) and the delete half (`beforeDelete`) of ONE invariant, off @@ -1856,6 +1911,35 @@ export function registerLastAdminGuard( await enforceStanding('organization-update', SystemObjectName.ORGANIZATION, ctx.input, data); }; + /** + * [ADR-0131 D5] The capture half: a slug changed TO `default` on an + * organization that is not the Default Organization + * ({@link refuseDefaultOrganizationSlugCapture}). A payload naming another + * slug, or none, costs no reads. Fails closed like every half: the rows a + * predicate write addresses, or the Default Organization itself, that cannot + * be read refuse the write. + */ + const guardOrganizationDefaultSlug = async (rawCtx: unknown): Promise => { + const ctx = ctxOf(rawCtx); + if (ctx.object !== SystemObjectName.ORGANIZATION) return; + const data = (ctx.input?.data ?? {}) as Record; + if (data.slug !== DEFAULT_ORGANIZATION_SLUG) return; + try { + const ids = await resolveTargetIds('organization-update', SystemObjectName.ORGANIZATION, ctx.input?.id, ctx.input?.options, data); + await refuseDefaultOrganizationSlugCapture(engine, ids, data.slug); + } catch (err) { + if (isRefusal(err)) throw err; + const reason = (err as Error)?.message ?? String(err); + throw refuse( + `Refusing this organization update: the organizations it addresses, or the Default Organization, ` + + `could not be read (${reason}), so whether the slug '${DEFAULT_ORGANIZATION_SLUG}' would make ` + + 'another organization the Default Organization is unknown (ADR-0131 D5). Retry once ' + + `'${SystemObjectName.ORGANIZATION}' is readable again.`, + SystemObjectName.ORGANIZATION, + ); + } + }; + const guardOrganizationDelete = async (rawCtx: unknown): Promise => { const ctx = ctxOf(rawCtx); if (ctx.object !== SystemObjectName.ORGANIZATION) return; @@ -1952,6 +2036,11 @@ export function registerLastAdminGuard( priority: 20, packageId, }); + engine.registerHook('beforeUpdate', guardOrganizationDefaultSlug, { + object: SystemObjectName.ORGANIZATION, + priority: 20, + packageId, + }); engine.registerHook('beforeDelete', guardOrganizationDelete, { object: SystemObjectName.ORGANIZATION, priority: 20, @@ -1962,7 +2051,8 @@ export function registerLastAdminGuard( '[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' + 'email/email_verified pair the deployment-config anchor derives from), sys_member and ' + 'sys_user_permission_set (standing revocation), sys_organization (creating, re-slugging ' + - 'or removing the Default Organization the grant anchor reads) and sys_metadata_activation ' + + 'or removing the Default Organization the grant anchor reads, and re-slugging another ' + + "organization to 'default') and sys_metadata_activation " + '(switching admin_full_access off) — ADR-0135 D5.2', ); diff --git a/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts new file mode 100644 index 00000000000..03fe682ff18 --- /dev/null +++ b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D5] Under `single` no slug change makes a non-Default organization + * the Default Organization. + * + * The Default Organization is the organization slugged `default`, else the + * only one, and its `admin_full_access` grant confers platform administrator + * standing (`grantAnchorsPlatformStanding`). On a store holding several + * organizations, none slugged `default`, an organization owner could re-slug + * their own organization to `default` through better-auth's + * `organization/update`: measured on `main` at 8dfd1265f0, the request + * answered 200, and a platform-granted `admin_full_access` grant that + * organization owned turned its holder from no platform administrator into one + * through the real `resolveAuthzContext`. A system-context engine update (a + * seed, a plugin) did the same. The data API's PATCH and update-mode import + * were already closed by the ADR-0092 identity write guard, which strips + * `slug` from a user-context write. + * + * Pinned here on the real stack (`ObjectQL` over `@objectstack/driver-sql` + + * better-sqlite3, this plugin's identity objects, the real last-administrator + * guard and a real `AuthManager` with the organization plugin), each refusal + * with its CONTROL: + * + * - the HTTP door: 403 `PERMISSION_DENIED`, the slug unchanged, no standing + * gained; the sole organization (already the Default Organization) and a + * slug other than `default` are admitted; a walled posture admits it, since + * a slug anchors nothing there; a read that fails is a 503; + * - the engine door, by id and by predicate: the same refusal, for a + * system-context write; walled, admitted. + */ + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resetPlatformAdminEmailMemo, resolveAuthzContext } from '@objectstack/core'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; + +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long-25271'; +const PASSWORD = 'S3cure!Passw0rd-25271'; +const SYSTEM = { context: { isSystem: true } } as never; +const ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; + +const text = (name: string) => ({ name, type: 'text' as const }); +/** The grant table the resolver and the guard read; declared locally, with the columns they read. */ +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 beside 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') }, +}; + +const 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 () => { + vi.restoreAllMocks(); + for (const [k, v] of Object.entries(ambient)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()!.destroy(); } catch { /* noop */ } + } +}); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +const post = (manager: AuthManager, path: string, body: unknown, cookie: string) => + manager.handleRequest(new Request(`${AUTH}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: BASE, cookie }, + body: JSON.stringify(body), + })); + +/** Does the resolver grade `userId` a platform administrator? */ +async function platformAdmin(engine: ObjectQL, userId: string): Promise { + const ctx = await resolveAuthzContext({ + ql: engine, + headers: {}, + getSession: () => ({ user: { id: userId }, session: { userId } }), + } as never); + return ctx.positions.includes('platform_admin'); +} + +async function slugOf(engine: ObjectQL, id: string): Promise { + const row = await engine.findOne('sys_organization', { where: { id }, context: { isSystem: true } } as never); + return (row as { slug?: unknown } | null)?.slug; +} + +/** 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 }; + } +} + +const answer = async (response: Response) => { + const body = (await response.clone().json().catch(() => null)) as { code?: unknown } | null; + return { status: response.status, code: body?.code ?? null }; +}; + +/** + * An owner of `org_b`, signed in with it active, holding an `admin_full_access` + * grant `org_b` owns. `organizations` are the store's organizations, `org_b` + * among them. + */ +async function ownerOfB(organizations: Array<{ id: string; slug: string }>) { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-security'); + engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-security'); + await engine.syncSchemas(); + bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); + registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.25271.last-admin-guard' }); + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never, plugins: { organization: true } } as never); + + const signedUp = await manager.handleRequest(new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: BASE }, + body: JSON.stringify({ email: 'owner-b@example.com', password: PASSWORD, name: 'Owner B' }), + })); + expect(signedUp.status, await signedUp.clone().text()).toBe(200); + const cookie = cookieFrom(signedUp); + const user = (await engine.findOne('sys_user', { where: { email: 'owner-b@example.com' }, context: { isSystem: true } } as never)) as { id: string }; + + for (const o of organizations) await engine.insert('sys_organization', { id: o.id, name: o.id, slug: o.slug }, SYSTEM); + await engine.insert('sys_member', { id: 'mem_b', user_id: user.id, organization_id: 'org_b', role: 'owner' }, SYSTEM); + await engine.insert('sys_user_permission_set', { id: 'ups_b', user_id: user.id, permission_set: ADMIN_FULL_ACCESS, organization_id: 'org_b' }, SYSTEM); + const active = await post(manager, '/organization/set-active', { organizationId: 'org_b' }, cookie); + expect(active.status, await active.clone().text()).toBe(200); + return { engine, manager, cookie, userId: user.id }; +} + +const TWO_NONE_DEFAULT = [{ id: 'org_a', slug: 'acme' }, { id: 'org_b', slug: 'beta' }]; +const reslug = (manager: AuthManager, cookie: string, slug: string) => + post(manager, '/organization/update', { organizationId: 'org_b', data: { slug } }, cookie); + +describe('[ADR-0131 D5] better-auth organization/update: no re-slug makes another organization the Default Organization', () => { + it('several organizations, none `default`: the owner re-slugging theirs to `default` is refused 403 PERMISSION_DENIED, and gains nothing', async () => { + const { engine, manager, cookie, userId } = await ownerOfB(TWO_NONE_DEFAULT); + expect(await platformAdmin(engine, userId)).toBe(false); + + const res = await reslug(manager, cookie, 'default'); + + expect(await answer(res)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(await slugOf(engine, 'org_b')).toBe('beta'); + expect(await platformAdmin(engine, userId)).toBe(false); + }); + + it('CONTROL: the sole organization is already the Default Organization, so it may take the slug `default`', async () => { + const { engine, manager, cookie, userId } = await ownerOfB([{ id: 'org_b', slug: 'beta' }]); + expect(await platformAdmin(engine, userId)).toBe(true); + + const res = await reslug(manager, cookie, 'default'); + + expect(res.status, await res.clone().text()).toBe(200); + expect(await slugOf(engine, 'org_b')).toBe('default'); + expect(await platformAdmin(engine, userId)).toBe(true); + }); + + it('CONTROL: a slug other than `default` is not this rule’s', async () => { + const { engine, manager, cookie } = await ownerOfB(TWO_NONE_DEFAULT); + + const res = await reslug(manager, cookie, 'gamma'); + + expect(res.status, await res.clone().text()).toBe(200); + expect(await slugOf(engine, 'org_b')).toBe('gamma'); + }); + + it('CONTROL: under a wall a slug anchors nothing, so the same re-slug is admitted', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + const { engine, manager, cookie } = await ownerOfB(TWO_NONE_DEFAULT); + + const res = await reslug(manager, cookie, 'default'); + + expect(res.status, await res.clone().text()).toBe(200); + expect(await slugOf(engine, 'org_b')).toBe('default'); + }); + + it('a read of the Default Organization that fails is a 503, and the slug is not changed', async () => { + const { engine, manager, cookie } = await ownerOfB(TWO_NONE_DEFAULT); + const real = engine.find.bind(engine); + vi.spyOn(engine, 'find').mockImplementation(async (object: string, query?: any, ...rest: any[]) => { + if (object === 'sys_organization' && query?.where?.slug === 'default') throw new Error('sys_organization store unavailable (injected)'); + return (real as any)(object, query, ...rest); + }); + + expect(await answer(await reslug(manager, cookie, 'default'))).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(await slugOf(engine, 'org_b')).toBe('beta'); + }); +}); + +describe('[ADR-0131 D5] the engine door (a seed, a plugin, a script): the same refusal for a system-context write', () => { + it('by id: refused 403 PERMISSION_DENIED, the slug unchanged, no standing gained', async () => { + const { engine, userId } = await ownerOfB(TWO_NONE_DEFAULT); + + expect(await refusalOf(engine.update('sys_organization', { id: 'org_b', slug: 'default' }, SYSTEM))).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await slugOf(engine, 'org_b')).toBe('beta'); + expect(await platformAdmin(engine, userId)).toBe(false); + }); + + it('a `default`-slugged Default Organization beside it: re-slugging another to `default` is refused by this rule, before any unique index', async () => { + // better-auth's own slug-taken check answers this shape 400 at the HTTP + // door before any hook runs; below HTTP nothing but this rule stands first. + const { engine } = await ownerOfB([{ id: 'org_default', slug: 'default' }, { id: 'org_b', slug: 'beta' }]); + + expect(await refusalOf(engine.update('sys_organization', { id: 'org_b', slug: 'default' }, SYSTEM))).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await slugOf(engine, 'org_b')).toBe('beta'); + }); + + it('by predicate: refused the same way', async () => { + const { engine } = await ownerOfB(TWO_NONE_DEFAULT); + + const write = engine.update('sys_organization', { slug: 'default' }, { multi: true, where: { id: 'org_b' }, ...(SYSTEM as object) } as never); + + expect(await refusalOf(write)).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await slugOf(engine, 'org_b')).toBe('beta'); + }); + + it('a read that fails refuses: the guard fails closed', async () => { + const { engine } = await ownerOfB(TWO_NONE_DEFAULT); + const real = engine.find.bind(engine); + vi.spyOn(engine, 'find').mockImplementation(async (object: string, query?: any, ...rest: any[]) => { + if (object === 'sys_organization' && query?.where?.slug === 'default') throw new Error('sys_organization store unavailable (injected)'); + return (real as any)(object, query, ...rest); + }); + + expect(await refusalOf(engine.update('sys_organization', { id: 'org_b', slug: 'default' }, SYSTEM))).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + vi.restoreAllMocks(); + expect(await slugOf(engine, 'org_b')).toBe('beta'); + }); + + it('CONTROL: under a wall the same write is admitted', async () => { + process.env.OS_TENANCY_POSTURE = 'group'; + const { engine } = await ownerOfB(TWO_NONE_DEFAULT); + + expect(await refusalOf(engine.update('sys_organization', { id: 'org_b', slug: 'default' }, SYSTEM))).toBeUndefined(); + expect(await slugOf(engine, 'org_b')).toBe('default'); + }); +}); From e205ce9dd450edeec1702bc38ae755a373215da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:55:30 +0000 Subject: [PATCH 2/5] fix(plugin-auth): the engine hook alone answers the HTTP door; drop the pre-check and the export Ablating the `beforeUpdateOrganization` pre-check showed better-auth's `organization/update` still answers 403 PERMISSION_DENIED: the adapter writes through the engine and the guard's refusal reaches the caller with its code. The pre-check changed only a read fault's answer (503 rather than the guard's fail-closed 403), so it goes, and with it the new public export: the judge is module-private to the last-administrator guard. Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude --- .../25271-single-reslug-default-anchor.md | 5 ++-- .../plugins/plugin-auth/src/auth-manager.ts | 30 ------------------- .../plugin-auth/src/last-admin-guard.ts | 13 ++++---- .../src/single-reslug-default-anchor.test.ts | 11 +++++-- 4 files changed, 16 insertions(+), 43 deletions(-) diff --git a/.changeset/25271-single-reslug-default-anchor.md b/.changeset/25271-single-reslug-default-anchor.md index 842e9fe2861..637465fa2a8 100644 --- a/.changeset/25271-single-reslug-default-anchor.md +++ b/.changeset/25271-single-reslug-default-anchor.md @@ -4,15 +4,14 @@ fix(plugin-auth)!: under `single`, no slug change makes another organization the Default Organization — re-slugging to `default` captured the platform-admin anchor -Clause-②: yes (narrowing) +Clause-②: no (narrowing) Metadata-protocol: none **BREAKING** for a `single` deployment that holds several organizations, none slugged `default`, and re-slugs one of them to `default`. Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). A re-slug to `default` therefore made that organization the Default Organization, and every holder of a grant it owned a platform administrator: an organization owner could do it through better-auth's `POST /auth/organization/update`, and a seed or a plugin through an engine update. -- **FROM:** a slug changed to `default` on any organization was admitted, as long as the last-administrator guard found an administrator left standing. **TO:** under `single` it is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Any other organization is refused `403 PERMISSION_DENIED`, on every door: better-auth's `organization/update` answers it before the vendor writes, and the last-administrator guard's `sys_organization` `beforeUpdate` hook refuses it for every engine write, `isSystem` included. A read that cannot answer refuses too: `503 SERVICE_UNAVAILABLE` at the HTTP door, `403 PERMISSION_DENIED` at the engine. +- **FROM:** a slug changed to `default` on any organization was admitted, as long as the last-administrator guard found an administrator left standing. **TO:** under `single` it is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Any other organization is refused `403 PERMISSION_DENIED` by the last-administrator guard's `sys_organization` `beforeUpdate` hook, for every engine write, `isSystem` included, so better-auth's `organization/update` answers it too. A read the guard cannot complete refuses as well. - **Unchanged:** a slug changed to any other value; every slug change under a walled posture (`isolated`, `group`), where no grant anchors and a slug is just a slug; the data API, whose user-context PATCH and update-mode import were already refused a `slug` by the ADR-0092 identity write guard. -- **New export:** `refuseDefaultOrganizationSlugCapture(engine, organizationIds, slug)`, the one judge both doors call. **What an operator does.** Keep another slug. To name a Default Organization on a `single` deployment that holds several organizations, hold one organization (merge the others into it; the sole organization is the Default Organization and may then take the slug `default`), or declare a walled posture (`OS_TENANCY_POSTURE=group` or `isolated`), as the tenancy organization census already prescribes. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index c9d53cffdb1..c3dd34d7bf3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -158,7 +158,6 @@ import type { TenancyService } from './tenancy-service.js'; import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js'; import type { CounterStore } from './rate-limit-storage.js'; import { GRANT_SET_NAME_FIELD } from './grant-set-name.js'; -import { refuseDefaultOrganizationSlugCapture } from './last-admin-guard.js'; import { isLastLocalCredentialHolder, LAST_LOCAL_CREDENTIAL_CODE, @@ -3883,35 +3882,6 @@ export class AuthManager { // whenever either read faulted. const rawEngine = this.config.dataEngine; if (!rawEngine) return; - - // [ADR-0131 D5] Under `single` a slug changed TO `default` makes the - // organization the Default Organization, whose `admin_full_access` - // grant confers platform administrator standing, so only the - // Default Organization may take it. The last-administrator guard's - // engine hook refuses that write on every door; asked here first, - // before the vendor writes, the refusal answers 403 with its code - // rather than the 500 better-call gives a throw from inside the - // adapter. Its reads run as the platform; one that throws is a 503, - // like this guard's own. - try { - await refuseDefaultOrganizationSlugCapture(rawEngine as never, new Set([String(orgId)]), newSlug); - } catch (err) { - const { APIError } = await import('better-auth/api'); - if ((err as { code?: unknown } | null)?.code === 'PERMISSION_DENIED') { - throw new APIError('FORBIDDEN', { message: (err as Error).message, code: 'PERMISSION_DENIED' }); - } - const fault = new APIError('SERVICE_UNAVAILABLE', { - message: - "The organization slug guard could not read `sys_organization`, so whether the slug 'default' " + - 'would make this organization the Default Organization was never determined, and the slug was ' + - 'not changed. This is a server-side outage, not a refusal of the new slug: retry once the data ' + - 'store is reachable.', - code: SLUG_GUARD_READ_FAULT_CODE, - }); - (fault as { cause?: unknown }).cause = err; - throw fault; - } - const schemaOf = (rawEngine as { getSchema?: (object: string) => unknown }).getSchema; if (typeof schemaOf === 'function' && !schemaOf.call(rawEngine, 'sys_environment')) return; diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index b3ffedf8deb..4698f51ad60 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.ts @@ -940,14 +940,13 @@ function touchesAny(data: Record, keys: readonly string[]): boo * a slug is just a slug, so nothing is read. A slug change to any other value * is not this rule's (the update half judges moving the anchor AWAY). * - * One judge, two callers: the guard's `beforeUpdate` hook, which every engine - * write reaches (better-auth's adapter, a seed, a plugin, the data API), and - * better-auth's `beforeUpdateOrganization`, which asks it before the vendor - * writes so the HTTP door answers 403 with the code instead of the 500 a - * refusal from inside the adapter would become. A read that throws - * propagates: each caller's read policy decides what an unreadable store means. + * Judged at the engine, on `beforeUpdate`, because every door that can change a + * slug is an engine update: better-auth's `organization/update` (its adapter + * writes through the engine, and the refusal reaches the caller as 403 + * `PERMISSION_DENIED`), a seed, a plugin, the data API. A read that throws + * propagates to the hook, which fails closed. */ -export async function refuseDefaultOrganizationSlugCapture( +async function refuseDefaultOrganizationSlugCapture( engine: Pick, organizationIds: ReadonlySet, slug: unknown, diff --git a/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts index 03fe682ff18..295701a2faa 100644 --- a/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts @@ -17,6 +17,11 @@ * were already closed by the ADR-0092 identity write guard, which strips * `slug` from a user-context write. * + * The refusal is the last-administrator guard's `sys_organization` + * `beforeUpdate` hook, so it holds on every one of those doors: better-auth's + * adapter writes through the engine, and its refusal reaches the HTTP caller + * as 403 `PERMISSION_DENIED`. + * * Pinned here on the real stack (`ObjectQL` over `@objectstack/driver-sql` + * better-sqlite3, this plugin's identity objects, the real last-administrator * guard and a real `AuthManager` with the organization plugin), each refusal @@ -25,7 +30,7 @@ * - the HTTP door: 403 `PERMISSION_DENIED`, the slug unchanged, no standing * gained; the sole organization (already the Default Organization) and a * slug other than `default` are admitted; a walled posture admits it, since - * a slug anchors nothing there; a read that fails is a 503; + * a slug anchors nothing there; a read that fails refuses; * - the engine door, by id and by predicate: the same refusal, for a * system-context write; walled, admitted. */ @@ -212,7 +217,7 @@ describe('[ADR-0131 D5] better-auth organization/update: no re-slug makes anothe expect(await slugOf(engine, 'org_b')).toBe('default'); }); - it('a read of the Default Organization that fails is a 503, and the slug is not changed', async () => { + it('a read of the Default Organization that fails refuses (the guard fails closed), and the slug is not changed', async () => { const { engine, manager, cookie } = await ownerOfB(TWO_NONE_DEFAULT); const real = engine.find.bind(engine); vi.spyOn(engine, 'find').mockImplementation(async (object: string, query?: any, ...rest: any[]) => { @@ -220,7 +225,7 @@ describe('[ADR-0131 D5] better-auth organization/update: no re-slug makes anothe return (real as any)(object, query, ...rest); }); - expect(await answer(await reslug(manager, cookie, 'default'))).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(await answer(await reslug(manager, cookie, 'default'))).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); expect(await slugOf(engine, 'org_b')).toBe('beta'); }); }); From 7aeec2d344e088c0275982236057ea4b1d2ac456 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:57:40 +0000 Subject: [PATCH 3/5] test(plugin-auth): the read-fault pins fail only the capture rule's own read Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude --- .../plugin-auth/src/single-reslug-default-anchor.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts index 295701a2faa..edf0d4fcf03 100644 --- a/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts @@ -219,9 +219,12 @@ describe('[ADR-0131 D5] better-auth organization/update: no re-slug makes anothe it('a read of the Default Organization that fails refuses (the guard fails closed), and the slug is not changed', async () => { const { engine, manager, cookie } = await ownerOfB(TWO_NONE_DEFAULT); + // Only this rule's read (`{ slug: 'default' }`, limit 1) fails: the + // break-glass half reads the same slug through a wider page, and must not + // be what refuses here. const real = engine.find.bind(engine); vi.spyOn(engine, 'find').mockImplementation(async (object: string, query?: any, ...rest: any[]) => { - if (object === 'sys_organization' && query?.where?.slug === 'default') throw new Error('sys_organization store unavailable (injected)'); + if (object === 'sys_organization' && query?.where?.slug === 'default' && query?.limit === 1) throw new Error('sys_organization store unavailable (injected)'); return (real as any)(object, query, ...rest); }); @@ -261,7 +264,7 @@ describe('[ADR-0131 D5] the engine door (a seed, a plugin, a script): the same r const { engine } = await ownerOfB(TWO_NONE_DEFAULT); const real = engine.find.bind(engine); vi.spyOn(engine, 'find').mockImplementation(async (object: string, query?: any, ...rest: any[]) => { - if (object === 'sys_organization' && query?.where?.slug === 'default') throw new Error('sys_organization store unavailable (injected)'); + if (object === 'sys_organization' && query?.where?.slug === 'default' && query?.limit === 1) throw new Error('sys_organization store unavailable (injected)'); return (real as any)(object, query, ...rest); }); From ec6d235613baecd4c80656db6fa92f391303b351 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:57:40 +0000 Subject: [PATCH 4/5] test(runtime): the memo-retirement golden moves `default` off, not onto, an organization The `default moved` case re-slugged a second organization to `default` through a system engine update: the anchor capture this card closes, now refused 403 PERMISSION_DENIED. The case keeps both memo directions on a path that stays legal: `default` moved off the Default Organization withdraws standing; the second organization deleted makes the first the only one again, which confers it. The capture itself is asserted refused. Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude --- ...ult-organization-statements.golden.test.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index 305679807a8..8c94c10b78e 100644 --- a/packages/runtime/src/default-organization-statements.golden.test.ts +++ b/packages/runtime/src/default-organization-statements.golden.test.ts @@ -21,7 +21,9 @@ * Then the two retirements the memo owes (#21868 is the failure designed * against), read over HTTP as the standing `get-session` reports: * - the Default Organization deleted and a new one created; - * - `default` moved from one organization to another, both directions. + * - `default` moved off the Default Organization (its grant stops anchoring) + * and the organization made the only one again (it anchors again); moving + * `default` onto another organization is refused (ADR-0131 D5). */ import { AsyncLocalStorage } from 'node:async_hooks'; @@ -241,19 +243,28 @@ describe('[#24523 / #21868] a sys_organization write retires the memoised Defaul expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); }); - it('⭐ `default` moved to another organization: the old one\'s grant stops anchoring, the new one\'s starts', async () => { + it('⭐ `default` moved off the Default Organization: its grant stops anchoring, and anchors again once it is the only one', async () => { const [from] = await find('sys_organization', { slug: 'default' }); const to = await engine.insert('sys_organization', { name: 'Second', slug: 'second' }, { context: SYS }); expect(await standing()).toEqual({ standing: true, organizationReads: 1 }); expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); await engine.update('sys_organization', { id: from.id, slug: 'first' }, { context: SYS }); - await engine.update('sys_organization', { id: to.id, slug: 'default' }, { context: SYS }); - // The granting direction: a memo still answering `from` would keep conferring standing. - expect(await standing()).toEqual({ standing: false, organizationReads: 1 }); + // The withdrawing direction: a memo still answering `from` would keep conferring standing. + expect((await standing()).standing).toBe(false); - const grant = await adminGrant(); - await engine.update('sys_user_permission_set', { id: grant.id, organization_id: to.id }, { context: SYS }); + // [ADR-0131 D5] `default` cannot be moved ONTO another organization: that + // is the platform-admin anchor captured, and the last-administrator guard + // refuses it for every context. + const refused = await engine.update('sys_organization', { id: to.id, slug: 'default' }, { context: SYS }).then(() => null, (e: any) => e); + expect(refused?.code).toBe('PERMISSION_DENIED'); + expect(refused?.status).toBe(403); + + // The conferring direction: `to` deleted, `from` is the only organization, + // so the Default Organization again. A memo still answering "none" would + // withhold standing here. + await engine.delete('sys_organization', { where: { id: to.id }, context: SYS }); + expect((await standing()).standing).toBe(true); expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); }); }); From 80315628e91fdcd3194b61d9489d3f4296be6143 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:23:38 +0000 Subject: [PATCH 5/5] docs(changeset): name the Default Organization move this removes, and the stranding it leaves Claude-Session: https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG Co-authored-by: Claude --- .changeset/25271-single-reslug-default-anchor.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/25271-single-reslug-default-anchor.md b/.changeset/25271-single-reslug-default-anchor.md index 637465fa2a8..5507a909ba3 100644 --- a/.changeset/25271-single-reslug-default-anchor.md +++ b/.changeset/25271-single-reslug-default-anchor.md @@ -12,6 +12,8 @@ Metadata-protocol: none **BREAKING** for a `single` deployment that holds several organizations, none slugged `default`, and re-slugs one of them to `default`. Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). A re-slug to `default` therefore made that organization the Default Organization, and every holder of a grant it owned a platform administrator: an organization owner could do it through better-auth's `POST /auth/organization/update`, and a seed or a plugin through an engine update. - **FROM:** a slug changed to `default` on any organization was admitted, as long as the last-administrator guard found an administrator left standing. **TO:** under `single` it is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Any other organization is refused `403 PERMISSION_DENIED` by the last-administrator guard's `sys_organization` `beforeUpdate` hook, for every engine write, `isSystem` included, so better-auth's `organization/update` answers it too. A read the guard cannot complete refuses as well. +- **Removed, with no in-place replacement: moving the Default Organization.** FROM: with several organizations, an operator could move `default` from one to another in two writes (the current one re-slugged away, then another re-slugged to `default`). TO: the second write is refused, because it is exactly the capture above, whoever makes it. An atomic swap was never possible (the slug is unique). +- **Stranding.** Re-slugging the Default Organization AWAY while other organizations exist is still admitted when an administrator stays standing (the last-administrator guard's update half, unchanged). After it, no organization names the Default Organization, so no organization can take `default`, the previous holder included, until the store holds one organization again or declares a wall. Do not re-slug a Default Organization away on a `single` deployment that holds several organizations. - **Unchanged:** a slug changed to any other value; every slug change under a walled posture (`isolated`, `group`), where no grant anchors and a slug is just a slug; the data API, whose user-context PATCH and update-mode import were already refused a `slug` by the ADR-0092 identity write guard. -**What an operator does.** Keep another slug. To name a Default Organization on a `single` deployment that holds several organizations, hold one organization (merge the others into it; the sole organization is the Default Organization and may then take the slug `default`), or declare a walled posture (`OS_TENANCY_POSTURE=group` or `isolated`), as the tenancy organization census already prescribes. +**What an operator does.** Keep another slug. To move the anchor, move the grant rather than the slug: an `admin_full_access` grant re-owned by the Default Organization anchors, and a grant owned elsewhere does not. To name a Default Organization on a `single` deployment that holds several organizations, hold one organization (merge the others into it; the sole organization is the Default Organization and may then take the slug `default`), or declare a walled posture (`OS_TENANCY_POSTURE=group` or `isolated`), as the tenancy organization census already prescribes.