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
49 changes: 49 additions & 0 deletions .changeset/last-admin-ban-break-glass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/plugin-auth": minor
---

feat(plugin-auth): break-glass — a ban may never leave the environment with zero administrators (#5892)

`sys_user.banned = true` is where every deprovision lands: better-auth's admin
plugin writes it, and `@better-auth/scim` maps a SCIM `active: false` onto that
same admin ban. Nothing checked what the write left behind — so **banning the
last administrator was allowed, reported success, and locked the organization
out of its own environment permanently.** SCIM makes that a realistic accident
rather than a hypothetical one: the write is driven by an external system, so
nobody reads the payload before it commits, and one mis-scoped IdP group is
enough.

**New guard (`last-admin-ban-guard.ts`, cloud ADR-0024 D5.2).** A `beforeUpdate`
hook on `sys_user` refuses any write that turns `banned` on when it would leave
the environment with **no unbanned administrator**. It sits on the write, not on
an endpoint, so it holds for the admin ban endpoint, the SCIM adapter write, an
import, a script, and anything added later — by-id **and** predicate/`multi`
writes alike.

Who counts as an administrator is exactly what the rest of the platform already
counts: a platform admin (an unscoped, in-window `admin_full_access` grant —
the same evidence `resolveAuthzContext` derives `platform_admin` from) or an
organization `owner`/`admin` membership. `delegated_admin` does not count
(ADR-0105 D8: it can reach an endpoint, it carries no authority), an expired
grant does not count, and the non-loginable `usr_system` account does not count.

Three consequences worth knowing before you upgrade:

- The refusal is a **403** carrying `PERMISSION_DENIED` and a message that names
the user, the invariant, and the fix (grant someone else `admin_full_access`
or an owner/admin membership first — and if an IdP drove the ban, the SCIM
deprovision is too broad). On the auth pipeline it now surfaces as a proper
`APIError` instead of an opaque 500.
- It **fails closed**: if the administrator population cannot be read, or is too
large to enumerate, the ban is refused rather than guessed at. The failure
mode being prevented is a permanent lockout.
- Writes that do not turn `banned` on — unbans, profile edits, re-banning an
already-banned admin — are untouched, and so is banning anyone who is not an
administrator.

The other half of the same invariant (`enforced` SSO must never disable the last
local admin's **password** — the escape hatch for an IdP outage) was already
implemented and is now pinned by tests rather than reimplemented:
`emailAndPassword.enabled` stays `true` under enforced SSO while sign-up is
forced off, and the last local `credential` account still cannot be banned,
removed or deleted.
15 changes: 15 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
registerManagedUpdateWhitelist,
type SecondaryStorageLike,
} from './identity-write-guard.js';
import { registerLastAdminBanGuard } from './last-admin-ban-guard.js';
import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js';
import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js';
import { runSetInitialPassword } from './set-initial-password.js';
Expand Down Expand Up @@ -986,6 +987,20 @@ export class AuthPlugin implements Plugin {
getSecondaryStorage: () =>
this.effectiveSecondaryStorage as SecondaryStorageLike | undefined,
});
// [cloud ADR-0024 D5.2] Break-glass — the SAME `sys_user` write
// chokepoint, guarding a different question: not "may this caller
// write identity tables" (above, and system writes bypass it by
// design) but "may this VALUE be written at all". A `banned = true`
// that would leave the environment with no administrator able to sign
// in is refused for EVERY context, `isSystem` included — because the
// path that actually locks an org out is the system one (better-auth's
// admin ban, driven by a SCIM `active: false`). Registered at
// priority 20 so the ADR-0092 strip above (10) still answers first for
// user-context callers. See last-admin-ban-guard.ts.
registerLastAdminBanGuard(engine, {
packageId: 'com.objectstack.plugin-auth.last-admin-ban-guard',
logger: ctx.logger,
});
} catch {
// Engine not available (mock mode) — permission-set defaults remain
// the only gate, exactly the pre-guard status quo.
Expand Down
232 changes: 232 additions & 0 deletions packages/plugins/plugin-auth/src/break-glass-local-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#5892 / cloud ADR-0024 D5.2] The PASSWORD half of the break-glass
* invariant — pinned, not implemented.
*
* #5892 asked for two things. The ban half was missing and is built in
* `last-admin-ban-guard.ts`; this half — "`enforced` SSO must never disable the
* last local admin's password" — was **already implemented** on `origin/main`
* and had no test of its own, which is the state that lets a security
* behaviour be refactored away without anything going red. So this file adds
* the pins, over the shipped code, unchanged:
*
* 1. **The escape hatch stays wired under enforced SSO.** `resolveSsoOnly()`
* forces `disableSignUp` on and tells the console to hide the password
* form (`features.ssoEnforced`), but it must NEVER touch
* `emailAndPassword.enabled` — the endpoint has to remain callable, or the
* "use a password" link the login UI keeps for the env owner leads
* nowhere the day the IdP is down (`auth-manager.ts`, the
* `emailAndPassword` block and `getPublicConfig`).
* 2. **The last local password cannot be removed.** The global before-hook
* refuses `/admin/ban-user`, `/admin/remove-user` and `/delete-user` when
* the target is the only user holding a `credential` account
* (`LAST_LOCAL_CREDENTIAL`). Under enforced SSO the managed team has no
* local credential at all, so that one account IS the escape hatch.
*
* The middleware is driven directly with a synthetic `ctx` — the same shape
* better-auth passes it (`path`, `body`, `context.adapter`) — because the
* decision under test is entirely a function of those three, and standing up
* a real better-auth server would test better-auth's router instead.
*
* Fail-OPEN is deliberate here and is NOT a drift from the ban guard's
* fail-closed posture: this check's failure mode is a blocked legitimate
* removal, whereas the ban guard's is a permanently locked-out environment.
* The last case below pins that direction so a future "make it consistent"
* refactor has to argue with a test.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isAPIError } from 'better-auth/api';
import { AuthManager } from './auth-manager';

// Mock better-auth so building the instance neither needs a database nor
// starts a server; the config object is what these assertions read.
vi.mock('better-auth', () => ({
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
}));

import { betterAuth } from 'better-auth';

const SECRET = 'test-secret-at-least-32-chars-long';
const BASE_URL = 'http://localhost:3000';

type CapturedConfig = {
emailAndPassword?: { enabled?: boolean; disableSignUp?: boolean };
hooks?: { before?: (ctx: unknown) => Promise<unknown> };
};

async function buildConfig(
options: Record<string, unknown> = {},
): Promise<{ config: CapturedConfig; manager: AuthManager }> {
let captured: CapturedConfig = {};
(betterAuth as unknown as { mockImplementation: (f: (c: CapturedConfig) => unknown) => void })
.mockImplementation((config: CapturedConfig) => {
captured = config;
return { handler: vi.fn(), api: {} };
});
const manager = new AuthManager({ secret: SECRET, baseUrl: BASE_URL, ...options } as never);
await manager.getAuthInstance();
return { config: captured, manager };
}

/**
* The `account` rows a deployment holds. `findOne` answers the target's own
* credential lookup; `findMany` answers "who else holds one".
*/
function adapterWithCredentials(holders: string[]) {
const rows = holders.map((userId) => ({ userId, providerId: 'credential' }));
return {
findOne: vi.fn(async ({ where }: { where: Array<{ field: string; value: unknown }> }) => {
const userId = where.find((w) => w.field === 'userId')?.value;
return rows.find((r) => r.userId === userId) ?? null;
}),
findMany: vi.fn(async () => rows),
};
}

let consoleSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
const prevMcp = process.env.OS_MCP_SERVER_ENABLED;
const prevSsoOnly = process.env.OS_AUTH_SSO_ONLY;

beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Keep the plugin list to the default surface — the MCP pair has its own
// coverage and only lengthens these boots.
process.env.OS_MCP_SERVER_ENABLED = 'false';
delete process.env.OS_AUTH_SSO_ONLY;
});

afterEach(() => {
consoleSpy.mockRestore();
warnSpy.mockRestore();
if (prevMcp === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
else process.env.OS_MCP_SERVER_ENABLED = prevMcp;
if (prevSsoOnly === undefined) delete process.env.OS_AUTH_SSO_ONLY;
else process.env.OS_AUTH_SSO_ONLY = prevSsoOnly;
});

// ---------------------------------------------------------------------------
// 1. Enforced SSO keeps the password endpoint alive
// ---------------------------------------------------------------------------

describe('[#5892] enforced SSO hides the password form — it never disables it', () => {
it('config knob: `emailAndPassword.enabled` stays true while sign-up is forced off', async () => {
const { config, manager } = await buildConfig({ ssoOnlyMode: true });

expect(config.emailAndPassword?.enabled).toBe(true);
expect(config.emailAndPassword?.disableSignUp).toBe(true);

const publicConfig = manager.getPublicConfig() as {
emailPassword: { enabled: boolean; disableSignUp: boolean };
features: { ssoEnforced: boolean };
};
// The console is told to HIDE the form (ssoEnforced) while the capability
// it hides is still advertised as enabled — that gap is the break-glass
// link, not an inconsistency.
expect(publicConfig.features.ssoEnforced).toBe(true);
expect(publicConfig.emailPassword.enabled).toBe(true);
expect(publicConfig.emailPassword.disableSignUp).toBe(true);
});

it('env knob: `OS_AUTH_SSO_ONLY` reaches the same place', async () => {
process.env.OS_AUTH_SSO_ONLY = 'true';
const { config, manager } = await buildConfig();

expect(config.emailAndPassword?.enabled).toBe(true);
expect(config.emailAndPassword?.disableSignUp).toBe(true);
expect(
(manager.getPublicConfig() as { features: { ssoEnforced: boolean } }).features.ssoEnforced,
).toBe(true);
});

it('a deployment that really wants passwords off can still say so explicitly', async () => {
// The invariant is "enforced SSO does not disable it", not "it can never be
// disabled" — otherwise the assertion above would pass against code that
// ignores the option entirely.
const { config } = await buildConfig({ emailAndPassword: { enabled: false } });
expect(config.emailAndPassword?.enabled).toBe(false);
});
});

// ---------------------------------------------------------------------------
// 2. The last local credential cannot be banned / removed / deleted
// ---------------------------------------------------------------------------

describe('[#5892] the last local password login survives ban / remove / delete', () => {
const BAN_PATHS = ['/admin/ban-user', '/admin/remove-user', '/delete-user'];

it('refuses the removal when the target holds the ONLY credential account', async () => {
const { config } = await buildConfig({ ssoOnlyMode: true });
const before = config.hooks?.before;
expect(typeof before).toBe('function');

for (const path of BAN_PATHS) {
const adapter = adapterWithCredentials(['usr_owner']);
let caught: unknown;
try {
await before!({ path, body: { userId: 'usr_owner' }, context: { adapter } });
} catch (e) {
caught = e;
}
expect(isAPIError(caught)).toBe(true);
const api = caught as { statusCode: number; body: { code?: string; message?: string } };
expect(api.body.code).toBe('LAST_LOCAL_CREDENTIAL');
expect(api.body.message).toMatch(/identity-\s*provider outage|provider outage/);
}
});

it('allows it when another user still holds a local password', async () => {
const { config } = await buildConfig({ ssoOnlyMode: true });
const adapter = adapterWithCredentials(['usr_owner', 'usr_second_admin']);

await expect(
config.hooks!.before!({
path: '/admin/ban-user',
body: { userId: 'usr_owner' },
context: { adapter },
}),
).resolves.toBeUndefined();
});

it('never fires for a credential-less (IdP-managed) target', async () => {
// The managed population signs in through the IdP and holds no local
// password, so removing one of them cannot cost anyone the escape hatch.
const { config } = await buildConfig({ ssoOnlyMode: true });
const adapter = adapterWithCredentials(['usr_owner']);

await expect(
config.hooks!.before!({
path: '/admin/ban-user',
body: { userId: 'usr_managed' },
context: { adapter },
}),
).resolves.toBeUndefined();
// Only the target's own lookup ran — the whole-table scan is skipped.
expect(adapter.findMany).not.toHaveBeenCalled();
});

it('fails OPEN on a lookup error — the opposite direction from the ban guard, on purpose', async () => {
const { config } = await buildConfig({ ssoOnlyMode: true });
const adapter = {
findOne: vi.fn(async () => {
throw new Error('account table unreadable');
}),
findMany: vi.fn(async () => []),
};

// A blocked legitimate removal is the cost here; a locked-out environment
// is the cost in `last-admin-ban-guard.ts`. Different failure modes,
// different directions — see that file's header.
await expect(
config.hooks!.before!({
path: '/admin/ban-user',
body: { userId: 'usr_owner' },
context: { adapter },
}),
).resolves.toBeUndefined();
});
});
6 changes: 6 additions & 0 deletions packages/plugins/plugin-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export * from './admin-user-endpoints.js';
export * from './placeholder-email.js';
export * from './admin-import-users.js';
export * from './identity-write-guard.js';
// [cloud ADR-0024 D5.2 / #5892] The break-glass ban guard. Exported for the
// same reason its ADR-0092 neighbour above is: a host that stands up its own
// ObjectQL engine (the cloud control plane, an embedding that skips this
// plugin's `kernel:ready` wiring) has to be able to register the invariant
// itself rather than ship an environment that can ban its last administrator.
export * from './last-admin-ban-guard.js';
export * from './sys-user-writable-fields.js';
export * from './otp-send-guard.js';
// ADR-0069 D2 / #4772 — the cross-node rate-limit counter store (kernel cache,
Expand Down
21 changes: 21 additions & 0 deletions packages/plugins/plugin-auth/src/invitation-role-cap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ export function orgRoleGrade(raw: unknown): number {
return grade;
}

/**
* Does this `sys_member.role` value carry an ADMINISTRATIVE grade — i.e. is
* its holder one of the people who administer the organization?
*
* The grade ladder above is the one place that answers it, so every consumer
* asks here rather than re-spelling `role === 'owner' || role === 'admin'`:
* a hand-written copy drops the comma-joined (`'owner,member'`) and array
* spellings `parseOrgRoles` handles, and on a security path that difference is
* silent. Second consumer, and the reason this is exported: the break-glass
* ban guard (`last-admin-ban-guard.ts`, ADR-0024 D5.2), which counts the
* administrators an environment would have left after a ban — a guard that
* mistook the only owner for an ordinary member would wave the lockout
* through.
*
* `delegated_admin` is NOT an administrative grade (ADR-0105 D8: it can reach
* an endpoint, it carries no authority), and neither is an unresolvable value.
*/
export function isOrgAdminGrade(raw: unknown): boolean {
return orgRoleGrade(raw) >= GRADE_ADMIN;
}

/**
* Is this invitation exactly a plain `member`? Such an invitation can never
* trip the cap, so the hook skips resolving the issuer's membership row — the
Expand Down
Loading
Loading