Skip to content

Commit 09e07c2

Browse files
fix(plugin-auth)!: under single, no slug change makes another organization the Default Organization (#25286)
Fixes #25271 Clause-②: no (narrowing) Metadata-protocol: none ## What was wrong (Step 1, measured on `main` at 8dfd126) 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). On a store holding two organizations, `org_a` slugged `acme` and `org_b` slugged `beta`, where `org_b` owns an `admin_full_access` grant held by its owner, measured on the real stack (`ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, the real last-administrator guard, a real `AuthManager` with the organization plugin, the real `resolveAuthzContext`): | door | before (8dfd126) | after (this PR) | |:---|:---|:---| | better-auth `POST /auth/organization/update` `{ organizationId: org_b, data: { slug: 'default' } }`, as `org_b`'s owner | **200**, slug `default`, holder `platform_admin` **false → true** | **403 `PERMISSION_DENIED`**, slug `beta`, `platform_admin` stays false | | engine update `{ id: org_b, slug: 'default' }`, `isSystem` (a seed's upsert, a plugin, a script) | **admitted**, `platform_admin` **false → true** | **403 `PERMISSION_DENIED`**, slug unchanged | | data API PATCH and update-mode import (engine update in user context, with the ADR-0092 identity write guard) | 403 `PERMISSION_DENIED`, `slug` is not an editable column; slug unchanged | unchanged | The last-administrator guard's `sys_organization` update half admitted the re-slug because it only refuses a write that leaves nobody standing (ADR-0135 D5.2), and this one takes standing from nobody. ## The fix, and why this one A new hook in `last-admin-guard.ts`, registered on `sys_organization` `beforeUpdate` beside the update half: under `single`, a slug changed **to** `default` is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Anything else is refused `403 PERMISSION_DENIED` (ADR-0112 envelope; the message names the Default Organization rule, the current standing, and the remedy: keep another slug, hold one organization, or declare a walled posture). It reads only when the payload names `slug: 'default'`, reads nothing under a wall, and fails closed when it cannot read the organizations (a predicate write's matches, or the Default Organization). - **Why the engine and not `beforeUpdateOrganization`.** Every door that can change a slug is an engine update: better-auth's adapter writes through the engine, and so do seeds, plugins and the data API. A better-auth hook alone would leave the seed and plugin doors open. Measured by ablation: with only the engine hook, better-auth's `organization/update` answers 403 with the code `PERMISSION_DENIED` (the guard's refusal reaches the caller through the adapter), so a second check in `auth-manager.ts` would buy nothing but a different answer to a read fault. A first commit carried that pre-check; it was removed, and with it the public export it needed. - **Why not gate every slug change the way create is gated.** That refuses renaming the slug of a single-organization deployment, which moves no anchor. The narrow rule refuses only the write that captures the anchor. - **Walled postures are unchanged**: no grant anchors there and a slug is just a slug; the hook returns before any read. ## Pins (`packages/plugins/plugin-auth/src/single-reslug-default-anchor.test.ts`, 10 cases) - HTTP door: refused 403 `PERMISSION_DENIED`, slug unchanged, no standing gained; CONTROL the sole organization takes `default` (200); CONTROL another slug (200); CONTROL walled `isolated` (200); a failed read of this rule's own query refuses. - Engine door: by id and by predicate, refused; a `default`-slugged Default Organization beside it, refused by this rule before any unique index (at HTTP, better-auth's own slug-taken check answers that shape 400 first); a failed read refuses; CONTROL walled `group`, admitted. - Ablations through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty (the suite imports `src`, no dist leg): the hook's registration removed → 6 red (every refusal pin), 4 controls green; the wall short-circuit removed → the 2 walled controls red; the Default-Organization admission removed → the sole-organization control red. Proof (tests, gates, R1, R4, the upgrade harness, dogfood) is in the os-dev-report on #25271. ## Acceptance notes - The refusal answers the standard `PERMISSION_DENIED`, the code every half of the last-administrator guard answers. A dedicated registered code would need a row in `packages/spec`'s error-code ledger, outside this card's file surface. - `docs/adr/**` is untouched. The rule is ADR-0131 D5's definition applied to the update write; the ADR's 2026-10-22 D5 note could name this hook beside the insert half when the epic next amends it. --- _Generated by [Claude Code](https://claude.ai/code/session_01S2kKLbAZ5FRjY2StxU3jVG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f30f00 commit 09e07c2

4 files changed

Lines changed: 410 additions & 8 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: under `single`, no slug change makes another organization the Default Organization — re-slugging to `default` captured the platform-admin anchor
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is a runtime write: a `sys_organization` slug changed to `default` under `single`. -->
11+
12+
**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.
13+
14+
- **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.
15+
- **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).
16+
- **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.
17+
- **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.
18+
19+
**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.

‎packages/plugins/plugin-auth/src/last-admin-guard.ts‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ import { postureEnforcesWall } from '@objectstack/spec/security';
325325
import { SystemObjectName, SystemUserId } from '@objectstack/spec/system';
326326
import { isMissingTableError, resolveTenancyPosture } from '@objectstack/types';
327327
import {
328+
DEFAULT_ORGANIZATION_SLUG,
328329
findDefaultOrganization,
329330
isGrantActive,
330331
matchesConfiguredPlatformAdmin,
@@ -923,6 +924,59 @@ function touchesAny(data: Record<string, unknown>, keys: readonly string[]): boo
923924
return keys.some((k) => k in data);
924925
}
925926

927+
/**
928+
* [ADR-0131 D5] Under `single` the Default Organization is the organization
929+
* slugged `default`, else the only one, and its `admin_full_access` grant
930+
* confers platform administrator standing. So re-slugging ANOTHER organization
931+
* to `default` makes it the Default Organization: on a store holding several
932+
* organizations, none slugged `default`, an organization owner who re-slugs
933+
* their own takes the anchor, and every holder of a grant it owns becomes a
934+
* platform administrator. The break-glass halves never see that, because it
935+
* takes standing from nobody.
936+
*
937+
* This refuses it: a slug changed TO `default` is admitted only on the
938+
* organization that is already the Default Organization (the sole one,
939+
* re-slugged to the name the boot gives it). Under a wall no grant anchors and
940+
* a slug is just a slug, so nothing is read. A slug change to any other value
941+
* is not this rule's (the update half judges moving the anchor AWAY).
942+
*
943+
* Judged at the engine, on `beforeUpdate`, because every door that can change a
944+
* slug is an engine update: better-auth's `organization/update` (its adapter
945+
* writes through the engine, and the refusal reaches the caller as 403
946+
* `PERMISSION_DENIED`), a seed, a plugin, the data API. A read that throws
947+
* propagates to the hook, which fails closed.
948+
*/
949+
async function refuseDefaultOrganizationSlugCapture(
950+
engine: Pick<LastAdminGuardEngine, 'find'>,
951+
organizationIds: ReadonlySet<string>,
952+
slug: unknown,
953+
): Promise<void> {
954+
if (slug !== DEFAULT_ORGANIZATION_SLUG || organizationIds.size === 0) return;
955+
const posture = resolveTenancyPosture();
956+
if (postureEnforcesWall(posture)) return;
957+
const { id, several } = await findDefaultOrganization((where, limit) =>
958+
engine.find(SystemObjectName.ORGANIZATION, { where: { ...where }, limit, fields: ['id'] }, SYSTEM_READ),
959+
);
960+
if (id !== undefined && organizationIds.size === 1 && organizationIds.has(id)) return;
961+
const named = [...organizationIds].map((o) => `'${o}'`).join(', ');
962+
const standing =
963+
id !== undefined
964+
? `This deployment's Default Organization is '${id}'.`
965+
: several
966+
? 'This deployment holds several organizations and none of them is the Default Organization.'
967+
: 'This deployment names no Default Organization.';
968+
throw refuse(
969+
`Refusing this organization update: under the '${posture}' tenancy posture the slug ` +
970+
`'${DEFAULT_ORGANIZATION_SLUG}' names the Default Organization, whose '${ADMIN_FULL_ACCESS}' grant ` +
971+
'confers platform administrator standing (ADR-0131 D5), so only the Default Organization may take ' +
972+
`it, and re-slugging ${named} to it would make that organization the Default Organization. ` +
973+
`${standing} Keep another slug. To name a Default Organization, hold one organization (the sole ` +
974+
`organization is the Default Organization and may then take the slug '${DEFAULT_ORGANIZATION_SLUG}'), ` +
975+
'or declare a walled tenancy posture (OS_TENANCY_POSTURE=group or isolated), where a slug anchors nothing.',
976+
SystemObjectName.ORGANIZATION,
977+
);
978+
}
979+
926980
/**
927981
* Register the last-administrator guard on an ObjectQL engine: the ban half
928982
* (`beforeUpdate`) and the delete half (`beforeDelete`) of ONE invariant, off
@@ -1856,6 +1910,35 @@ export function registerLastAdminGuard(
18561910
await enforceStanding('organization-update', SystemObjectName.ORGANIZATION, ctx.input, data);
18571911
};
18581912

1913+
/**
1914+
* [ADR-0131 D5] The capture half: a slug changed TO `default` on an
1915+
* organization that is not the Default Organization
1916+
* ({@link refuseDefaultOrganizationSlugCapture}). A payload naming another
1917+
* slug, or none, costs no reads. Fails closed like every half: the rows a
1918+
* predicate write addresses, or the Default Organization itself, that cannot
1919+
* be read refuse the write.
1920+
*/
1921+
const guardOrganizationDefaultSlug = async (rawCtx: unknown): Promise<void> => {
1922+
const ctx = ctxOf(rawCtx);
1923+
if (ctx.object !== SystemObjectName.ORGANIZATION) return;
1924+
const data = (ctx.input?.data ?? {}) as Record<string, unknown>;
1925+
if (data.slug !== DEFAULT_ORGANIZATION_SLUG) return;
1926+
try {
1927+
const ids = await resolveTargetIds('organization-update', SystemObjectName.ORGANIZATION, ctx.input?.id, ctx.input?.options, data);
1928+
await refuseDefaultOrganizationSlugCapture(engine, ids, data.slug);
1929+
} catch (err) {
1930+
if (isRefusal(err)) throw err;
1931+
const reason = (err as Error)?.message ?? String(err);
1932+
throw refuse(
1933+
`Refusing this organization update: the organizations it addresses, or the Default Organization, ` +
1934+
`could not be read (${reason}), so whether the slug '${DEFAULT_ORGANIZATION_SLUG}' would make ` +
1935+
'another organization the Default Organization is unknown (ADR-0131 D5). Retry once ' +
1936+
`'${SystemObjectName.ORGANIZATION}' is readable again.`,
1937+
SystemObjectName.ORGANIZATION,
1938+
);
1939+
}
1940+
};
1941+
18591942
const guardOrganizationDelete = async (rawCtx: unknown): Promise<void> => {
18601943
const ctx = ctxOf(rawCtx);
18611944
if (ctx.object !== SystemObjectName.ORGANIZATION) return;
@@ -1952,6 +2035,11 @@ export function registerLastAdminGuard(
19522035
priority: 20,
19532036
packageId,
19542037
});
2038+
engine.registerHook('beforeUpdate', guardOrganizationDefaultSlug, {
2039+
object: SystemObjectName.ORGANIZATION,
2040+
priority: 20,
2041+
packageId,
2042+
});
19552043
engine.registerHook('beforeDelete', guardOrganizationDelete, {
19562044
object: SystemObjectName.ORGANIZATION,
19572045
priority: 20,
@@ -1962,7 +2050,8 @@ export function registerLastAdminGuard(
19622050
'[LastAdminGuard] last-administrator guard registered on sys_user (ban + delete, and the ' +
19632051
'email/email_verified pair the deployment-config anchor derives from), sys_member and ' +
19642052
'sys_user_permission_set (standing revocation), sys_organization (creating, re-slugging ' +
1965-
'or removing the Default Organization the grant anchor reads) and sys_metadata_activation ' +
2053+
'or removing the Default Organization the grant anchor reads, and re-slugging another ' +
2054+
"organization to 'default') and sys_metadata_activation " +
19662055
'(switching admin_full_access off) — ADR-0135 D5.2',
19672056
);
19682057

0 commit comments

Comments
 (0)