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
16 changes: 16 additions & 0 deletions .changeset/25364-last-admin-guard-read-fault-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth): the last-administrator guard answers a read it could not complete with 503 `SERVICE_UNAVAILABLE`, the code the sibling guards on the same doors answer their read faults with

Clause-②: no
Metadata-protocol: none

The last-administrator guard fails closed: when a read its verdict depends on throws, it refuses the write. That refusal answered 403 `PERMISSION_DENIED`, the same code as a refusal on the merits, so a caller could not tell "you may not" from "try again" by the code. The organization slug guard (`auth-manager.ts`) and the organization delete admission already answer their read faults with 503 `SERVICE_UNAVAILABLE`.

- **Every half of the guard, one shape.** The shared fail-closed envelope and the Default Organization slug-capture rule now throw the same read fault: `code: 'SERVICE_UNAVAILABLE'`, `status: 503`, the read's own failure on `cause`. The write is still refused and nothing is written. A refusal on the merits, and an enumeration that completed but returned more rows than the guard can judge, keep 403 `PERMISSION_DENIED` unchanged.
- **The data API** answers the read fault 503 `SERVICE_UNAVAILABLE` with the server-fault message, as it does every declared 5xx.
- **better-auth's endpoints** (`organization/update`, the admin ban and remove-user endpoints, and every other endpoint that writes through the adapter) answer it 503 `SERVICE_UNAVAILABLE`. The adapter now maps any engine error that declares `status: 503` and `code: 'SERVICE_UNAVAILABLE'` to that answer; before, such an error reached better-auth's router as an unhandled 500 with an empty body. The driver's message is not relayed.
- **`organization/delete`** of the Default Organization answers the guard's read fault 503 `SERVICE_UNAVAILABLE`, the code the admission's own read faults answer with. A refusal on the merits there stays 403 `PERMISSION_DENIED`.
- **For a client:** branch on the code. `SERVICE_UNAVAILABLE` means retry once the data store is reachable; `PERMISSION_DENIED` means the write itself is refused.
21 changes: 19 additions & 2 deletions packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,26 @@ export async function assertOrganizationDeletable(this: Pick<ActorGuardHost, 'ge
try {
await this.organizationDeleteStandingJudge(organizationId);
} catch (err) {
if ((err as { code?: unknown } | null)?.code !== 'PERMISSION_DENIED') throw err;
const code = (err as { code?: unknown } | null)?.code;
const { APIError } = await import('better-auth/api');
throw new APIError('FORBIDDEN', { message: (err as Error).message, code: 'PERMISSION_DENIED' });
if (code === 'PERMISSION_DENIED') {
throw new APIError('FORBIDDEN', { message: (err as Error).message, code: 'PERMISSION_DENIED' });
}
// The guard's fail-closed answer to a lookup it could not complete (503,
// `last-admin-guard.ts` `readFault`): this admission's own read-fault
// code, and never the driver's message, which names the failed read.
if (code === ORGANIZATION_DELETE_READ_FAULT_CODE && (err as { status?: unknown }).status === 503) {
const fault = new APIError('SERVICE_UNAVAILABLE', {
code: ORGANIZATION_DELETE_READ_FAULT_CODE,
message:
'The organization delete could not verify the remaining administrators, so whether this ' +
'organization may be deleted was never decided, and nothing was deleted. This is a server-side ' +
'outage, not a refusal of the delete: retry once the data store is reachable.',
});
(fault as { cause?: unknown }).cause = err;
throw fault;
}
throw err;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* engine over better-sqlite3 `:memory:`, so the engine dispatches the hooks.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
Expand Down Expand Up @@ -266,6 +266,21 @@ describe('[#24948] the same question about deleting the Default Organization, as
expect((await members(engine)).map((m) => m.id)).toEqual(['mem_owner']);
});

it('a read the question cannot complete refuses with the read-fault code, once: an inner envelope\'s answer passes the outer one unchanged', async () => {
const engine = await promotedSingle();
const real = engine.find.bind(engine);
vi.spyOn(engine, 'find').mockImplementation(async (object: string, ...rest: any[]) => {
if (object === 'sys_user_permission_set') throw new Error('sys_user_permission_set is unreadable');
return (real as any)(object, ...rest);
});

const err = (await guard.judgeOrganizationDelete('org_default').catch((e: unknown) => e)) as Error & { code?: unknown; status?: unknown };
vi.restoreAllMocks();
expect(err).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 });
expect(err.message.match(/SERVICE_UNAVAILABLE:/g)).toHaveLength(1);
expect(err.message).toContain('sys_user_permission_set is unreadable');
});

it('CONTROL: with another administrator standing elsewhere, the delete is admitted', async () => {
const engine = await promotedSingle({ secondOrganization: true, grantOwner: 'org_b' });
await engine.insert('sys_organization', { id: 'org_c', name: 'C', slug: 'c' }, SYSTEM);
Expand Down
12 changes: 9 additions & 3 deletions packages/plugins/plugin-auth/src/last-admin-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,9 @@ describe('[#5892] the guard fails CLOSED — an unverifiable population refuses
// because the guard could not prove it.
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/could not be verified/i);
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/sys_member is unreadable/);
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
// [ADR-0112] Refused, but no verdict: the read-fault code, not the 403 a
// refusal on the merits answers.
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 });
expect(await bannedFlag(engine, 'usr_admin')).toBeFalsy();
});

Expand All @@ -601,6 +603,9 @@ describe('[#5892] the guard fails CLOSED — an unverifiable population refuses
await seedUser(engine, 'usr_admin', { role: 'admin' });

await expect(ban(engine, 'usr_admin')).rejects.toThrow(/more than 1 rows/);
// A read that COMPLETED and proved too large is a refusal with a remedy,
// not a read fault: it keeps the 403.
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
});

it('an environment with no administrator at all is not blocked (nothing to protect)', async () => {
Expand Down Expand Up @@ -935,7 +940,8 @@ describe('[#5941] the delete guard fails CLOSED too', () => {
await expect(removeUser(engine, 'usr_admin')).rejects.toThrow(/could not be verified/i);
await expect(removeUser(engine, 'usr_admin')).rejects.toThrow(/sys_member is unreadable/);
await expect(removeUser(engine, 'usr_admin')).rejects.toMatchObject({
code: 'PERMISSION_DENIED',
code: 'SERVICE_UNAVAILABLE',
status: 503,
});
expect(await userExists(engine, 'usr_admin')).toBe(true);
});
Expand Down Expand Up @@ -1557,7 +1563,7 @@ describe('[#5978] the standing halves fail CLOSED', () => {

await expect(
engine.update('sys_member', { id: 'mem_usr_admin', role: 'member' }, SYSTEM),
).rejects.toMatchObject({ code: 'PERMISSION_DENIED', object: 'sys_member' });
).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503, object: 'sys_member' });
expect(await memberRole(engine, 'mem_usr_admin')).toBe('admin');
});

Expand Down
61 changes: 54 additions & 7 deletions packages/plugins/plugin-auth/src/last-admin-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@
* reach the identity tables reach this guard.
*/

import type { StandardErrorCode } from '@objectstack/spec/api';
import type { BaseEngineOptions, EngineQueryOptions } from '@objectstack/spec/data';
import {
ADMIN_FULL_ACCESS,
Expand Down Expand Up @@ -636,6 +637,44 @@ function isRefusal(err: unknown): boolean {
return (err as { code?: string } | null)?.code === 'PERMISSION_DENIED';
}

/**
* [ADR-0112] The code every half of this guard answers a read it could not
* complete with: the standard catalog's member for a store that cannot answer
* right now, the code the organization slug guard's read fault
* (`auth-manager.ts`, `slugGuardReadFaultApiError`) and the organization delete
* admission's read fault answer with too. One code for the fault across every
* guard on a door, so a caller tells "you may not" (403 `PERMISSION_DENIED`)
* from "try again" by the code alone.
*/
const LAST_ADMIN_GUARD_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILABLE';

/**
* The fail-closed answer to a read the guard could not complete: 503
* {@link LAST_ADMIN_GUARD_READ_FAULT_CODE}. Still a refusal of the write — the
* hook throws, so nothing is written — but no verdict about it, so it does not
* wear {@link refuse}'s 403. The read's own failure rides `cause`. `object`
* names the table the caller was writing, as {@link refuse}'s does.
*/
function readFault(message: string, object: string, cause: unknown): Error {
const err = new Error(`${LAST_ADMIN_GUARD_READ_FAULT_CODE}: ${message}`) as Error & {
code?: string;
status?: number;
object?: string;
cause?: unknown;
};
err.code = LAST_ADMIN_GUARD_READ_FAULT_CODE;
err.status = 503;
err.object = object;
err.cause = cause;
return err;
}

/** Marker so a nested fail-closed wrapper re-throws an inner read fault unchanged. */
function isReadFault(err: unknown): boolean {
const e = err as { code?: unknown; status?: unknown } | null;
return e?.code === LAST_ADMIN_GUARD_READ_FAULT_CODE && e.status === 503;
}

const BREAK_GLASS_CITATION =
'break-glass invariant, ADR-0135 D5.2 — an environment must always keep at least one ' +
'administrator who can sign in';
Expand Down Expand Up @@ -945,7 +984,7 @@ function touchesAny(data: Record<string, unknown>, keys: readonly string[]): boo
* 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.
* propagates to the hook, which fails closed with 503 `SERVICE_UNAVAILABLE`.
*/
async function refuseDefaultOrganizationSlugCapture(
engine: Pick<LastAdminGuardEngine, 'find'>,
Expand Down Expand Up @@ -1573,24 +1612,30 @@ export function registerLastAdminGuard(
* The fail-CLOSED envelope every half runs inside. A lookup that throws is
* not "probably fine": the guard could not prove another administrator
* survives, and the cost of guessing wrong is a permanently locked-out
* environment. A deliberate refusal from inside passes through unchanged.
* environment. A deliberate refusal from inside passes through unchanged,
* and so does a read fault an inner envelope already answered.
*
* The write is refused either way; only the code tells the two apart: a
* verdict is 403 `PERMISSION_DENIED` ({@link refuse}), a lookup that could
* not complete is 503 `SERVICE_UNAVAILABLE` ({@link readFault}).
*/
const failClosed = async (op: GuardedOp, judge: () => Promise<void>): Promise<void> => {
const words = OP_WORDS[op];
try {
await judge();
} catch (err) {
if (isRefusal(err)) throw err;
if (isRefusal(err) || isReadFault(err)) throw err;
const reason = (err as Error)?.message ?? String(err);
logger?.warn(
`[LastAdminGuard] administrator lookup failed — ${words.noun} refused: ${reason}`,
);
throw refuse(
throw readFault(
`Refusing this ${words.noun}: the remaining administrators could not be verified ` +
`(${reason}). This guard fails closed — a ${words.noun} is only permitted when at ` +
`least one other unbanned administrator is provably left (${BREAK_GLASS_CITATION}). ` +
'Retry once the identity tables are readable again.',
words.table,
err,
);
}
};
Expand Down Expand Up @@ -1920,7 +1965,8 @@ export function registerLastAdminGuard(
* ({@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.
* be read refuse the write, with the same 503 read-fault code
* ({@link readFault}) as {@link failClosed}.
*/
const guardOrganizationDefaultSlug = async (rawCtx: unknown): Promise<void> => {
const ctx = ctxOf(rawCtx);
Expand All @@ -1931,14 +1977,15 @@ export function registerLastAdminGuard(
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;
if (isRefusal(err) || isReadFault(err)) throw err;
const reason = (err as Error)?.message ?? String(err);
throw refuse(
throw readFault(
`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,
err,
);
}
};
Expand Down
44 changes: 38 additions & 6 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,27 @@ function isEnginePolicyRefusal(err: unknown): err is { code?: string; message?:
return (err as { code?: unknown }).code === 'PERMISSION_DENIED';
}

/**
* A declared READ FAULT — an engine-level guard that could not complete a read
* its verdict depends on, and so refused the write without reaching one
* (`SERVICE_UNAVAILABLE`, 503, ADR-0112). The concrete case is the
* last-administrator guard's fail-closed envelope (`last-admin-guard.ts`,
* `readFault`): without this arm its refusal reached better-auth's router as an
* unhandled fault, a 500 with an empty body.
*
* Read by the declared `code` AND `status`, both, the way `mapDataError`'s
* declared-5xx relay reads them for the REST data routes, so the two
* transports answer the same engine error with the same status and code. The
* engine's prose is not relayed, as that relay withholds a declared fault's:
* the guard's message names the driver's own failure, which is for the server
* log, not the caller.
*/
function isEngineReadFault(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as { code?: unknown; status?: unknown };
return e.code === 'SERVICE_UNAVAILABLE' && e.status === 503;
}

/**
* [#7724] A REFERENTIAL refusal — the engine's `cascadeDeleteRelations` found
* dependent rows it may neither cascade nor null, so it vetoed the delete
Expand Down Expand Up @@ -607,9 +628,9 @@ function wireDependents(raw: unknown): Array<{ object: string; count?: number }>

/**
* Re-throw `err` as a better-auth `APIError` when it is an ObjectQL validation
* failure (400), an engine policy refusal (403) or a referential delete
* restriction (409); otherwise re-throw it verbatim. Always throws — the return
* type is `never`.
* failure (400), an engine policy refusal (403), a referential delete
* restriction (409) or a declared engine read fault (503); otherwise re-throw
* it verbatim. Always throws — the return type is `never`.
*/
async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
if (isObjectQLValidationError(err)) {
Expand All @@ -634,6 +655,17 @@ async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
code: 'PERMISSION_DENIED',
});
}
if (isEngineReadFault(err)) {
const { APIError } = await import('better-auth/api');
const apiErr = new APIError('SERVICE_UNAVAILABLE', {
message:
'This request could not be completed: the data store could not answer a read it depends on. ' +
'This is a server-side outage, not a refusal of the request: retry once the data store is reachable.',
code: 'SERVICE_UNAVAILABLE',
});
(apiErr as { cause?: unknown }).cause = err;
throw apiErr;
}
if (isReferentialDeleteRestriction(err)) {
const { APIError } = await import('better-auth/api');
throw new APIError('CONFLICT', {
Expand All @@ -655,9 +687,9 @@ async function rethrowAsBetterAuthError(err: unknown): Promise<never> {

/**
* Wrap every function-valued method of a better-auth adapter so an ObjectQL
* `ValidationError` (400), an engine policy refusal (403) or a referential
* delete restriction (409, #7724) thrown from the underlying engine surfaces as
* a 4xx `APIError` instead of an opaque 500. Non-function properties pass
* `ValidationError` (400), an engine policy refusal (403), a referential
* delete restriction (409, #7724) or a declared engine read fault (503) thrown
* from the underlying engine surfaces as an `APIError` instead of an opaque 500. Non-function properties pass
* through untouched, and every error that carries none of those signatures is
* re-thrown verbatim.
*/
Expand Down
Loading
Loading