Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/25271-single-reslug-default-anchor.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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`. -->

**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.
91 changes: 90 additions & 1 deletion packages/plugins/plugin-auth/src/last-admin-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -923,6 +924,59 @@ function touchesAny(data: Record<string, unknown>, 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<LastAdminGuardEngine, 'find'>,
organizationIds: ReadonlySet<string>,
slug: unknown,
): Promise<void> {
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
Expand Down Expand Up @@ -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<void> => {
const ctx = ctxOf(rawCtx);
if (ctx.object !== SystemObjectName.ORGANIZATION) return;
const data = (ctx.input?.data ?? {}) as Record<string, unknown>;
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<void> => {
const ctx = ctxOf(rawCtx);
if (ctx.object !== SystemObjectName.ORGANIZATION) return;
Expand Down Expand Up @@ -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,
Expand All @@ -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',
);

Expand Down
Loading
Loading