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

feat(plugin-auth): break-glass — the last administrator cannot be DELETED either (#5941)

#5892 closed the *ban* half of ADR-0024 D5.2's break-glass invariant. The
**delete** half was still open, and it was reachable end to end: in an enforced
SSO environment the last administrator is typically IdP-managed and holds no
local password, so when the IdP drops them from the admin group the resulting
SCIM `DELETE /Users/{id}` (or `/admin/remove-user`, or `/delete-user`) removed
the row and **left the environment with nobody able to administer it** — quite
possibly with a password-holding non-admin still able to sign in and change
nothing. There is no recovery path from inside the product once that happens.

The pre-existing HTTP guard on those three endpoints did not cover it: it
protects the last holder of a local `credential` account, so it skips the
credential-less (IdP-managed) target entirely. It is unchanged and keeps
enforcing its own invariant.

**What changed.** The guard module now enforces one invariant on *both* writes
that can take the last administrator away, off one administrator enumeration:

| write | hook |
|:--|:--|
| `sys_user.banned = true` | `beforeUpdate` (#5892) |
| deleting the `sys_user` row | `beforeDelete` (**new**) |

The delete half is the ban half's twin in every property that matters: it sits
on the **write**, so it holds for the SCIM adapter delete, better-auth's admin
remove-user, an import and a script alike; it covers by-id **and**
predicate/`multi` deletes (including the unpredicated `multi` that would empty
the table); it applies to **every** context, `isSystem` included, because the
deprovision path that actually locks organizations out is the system one; and it
**fails closed** — an administrator population that cannot be read, or is too
large to enumerate, refuses the delete rather than guessing.

The refusal is a **403** carrying `PERMISSION_DENIED` and names the operation
the caller actually attempted ("Refusing to delete 'usr_…'"), the invariant
(ADR-0024 D5.2), and the fix — grant someone else `admin_full_access` or an
owner/admin membership first, and if an IdP drove it, the SCIM deprovision is
too broad. On the auth pipeline it surfaces as an `APIError`, not an opaque 500.

Untouched: deleting anyone who is not an administrator, deleting an
administrator while another unbanned one remains, and deleting an administrator
who is already banned (that account could not sign in either way).

**Rename.** The module is now `last-admin-guard.ts` and the exported registration
function is `registerLastAdminGuard` (was `last-admin-ban-guard.ts` /
`registerLastAdminBanGuard`, added in the same unreleased cycle) — it registers
both hooks, so the old name would have understated what it installs. Hosts that
wire the guard onto their own ObjectQL engine rename the import; there is no
other change to its signature or behaviour.

Not covered, tracked separately (#5978): revoking the *standing* that makes
someone an administrator — deleting or downgrading their `sys_member` row,
removing the `admin_full_access` grant — leaves the user row in place and writes
a different table, so neither hook sees it.
23 changes: 12 additions & 11 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
registerManagedUpdateWhitelist,
type SecondaryStorageLike,
} from './identity-write-guard.js';
import { registerLastAdminBanGuard } from './last-admin-ban-guard.js';
import { registerLastAdminGuard } from './last-admin-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 @@ -988,17 +988,18 @@ export class AuthPlugin implements Plugin {
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
// chokepoints, 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',
// design) but "may this WRITE happen at all". A `banned = true` (#5892)
// or a row DELETE (#5941) that would leave the environment with no
// administrator able to sign in is refused for EVERY context,
// `isSystem` included — because the paths that actually lock an org out
// are the system ones (better-auth's admin ban and remove-user, driven
// by a SCIM `active: false` / `DELETE /Users/{id}`). Registered at
// priority 20 so the ADR-0092 checks above (10) still answer first for
// user-context callers. See last-admin-guard.ts.
registerLastAdminGuard(engine, {
packageId: 'com.objectstack.plugin-auth.last-admin-guard',
logger: ctx.logger,
});
} catch {
Expand Down
14 changes: 8 additions & 6 deletions packages/plugins/plugin-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ 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';
// [cloud ADR-0024 D5.2 / #5892 / #5941] The break-glass guard — one invariant
// on both writes that can take the last administrator away (`banned = true`
// and deleting the `sys_user` row). 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 or delete its last administrator.
export * from './last-admin-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
Loading
Loading