diff --git a/.changeset/25271-single-reslug-default-anchor.md b/.changeset/25271-single-reslug-default-anchor.md new file mode 100644 index 0000000000..5507a909ba --- /dev/null +++ b/.changeset/25271-single-reslug-default-anchor.md @@ -0,0 +1,19 @@ +--- +'@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-②: 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` 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 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. diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.ts b/packages/plugins/plugin-auth/src/last-admin-guard.ts index 5aef66fb5c..4698f51ad6 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,59 @@ 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). + * + * 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. + */ +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 +1910,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 +2035,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 +2050,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 0000000000..edf0d4fcf0 --- /dev/null +++ b/packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts @@ -0,0 +1,283 @@ +// 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. + * + * 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 + * 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 refuses; + * - 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 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' && query?.limit === 1) 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: 403, code: 'PERMISSION_DENIED' }); + 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' && query?.limit === 1) 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'); + }); +}); diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index 305679807a..8c94c10b78 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 }); }); });