auth: the role column decides who owns the server

There were three answers to "who is the owner" and nothing kept them agreeing:
SUPER_ADMIN_EMAIL in .env, the lowest user id, and now the role column. Any two
of them part company the moment one is edited, and the failure is silent — an
account quietly gains or loses the vault, the platform origin, and the identity
the agent sidecar runs as. The column wins; the other two are gone.

- isSuperAdmin() reads users.role. SUPER_ADMIN_EMAIL is deleted from the code and
  from .env.
- getOwnerUser() selects on the role instead of `order by id limit 1`. It returns
  undefined when no row holds it rather than falling back: the agent sidecar
  refusing to start beats it silently running as the wrong person.
- bootstrap creates the first account with role 'Super Admin'. Without this a
  fresh install would take the column's 'Member' default and come up with NO
  owner at all — no vault, no agent identity, and the web origin locked to a
  Super Admin that does not exist. That bug was live the moment the column
  landed; SUPER_ADMIN_EMAIL was masking it here.

Deliberately not cached. The old resolver cached an owner id, justified by "the
owner never changes at runtime (bootstrap is closed after user #1)" — which stops
being true as soon as roles are editable, and a cache with no invalidation
contract is a staleness bug waiting for whoever builds the role UI. It is one
primary-key lookup on a request that has already verified a JWT.

Verified against the live database: getOwnerUser resolves to id 1, isSuperAdmin
is true for it and false for the three Members, for a null payload and for an id
that does not exist. Then temporarily set id 13 to 'Admin' (false) and to
'Super Admin' (true) with no restart in between, which is what proves the column
is doing the deciding rather than a cache or the old lowest-id path. Reverted.

Not solved here: nothing stops the last Super Admin being demoted, which would
leave the platform ownerless. The schema cannot express it; whatever eventually
edits roles has to. Noted in the column's comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:42:35 +00:00
co-authored by Claude Opus 5
parent c0e7364a90
commit 8de3b1908c
4 changed files with 40 additions and 40 deletions
+9 -4
View File
@@ -19,11 +19,16 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
return user;
}
// Single-user platform: there is exactly one account, created once by POST /auth/bootstrap. Sidecars
// that need "who is the owner" (e.g. the agent sidecar, which PM2 starts with no email in its env)
// resolve it here rather than being told by the main server.
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
// the main server.
//
// This was `order by id limit 1` — the first account created. That was the same claim the role column
// now makes, asserted a second way, and the two would part company the moment the owner was not user
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
// sidecar refusing to start beats it silently running as the wrong person.
export async function getOwnerUser(): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).orderBy(users.id).limit(1);
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).limit(1);
return user;
}
+5 -2
View File
@@ -7,8 +7,11 @@ import { pgTable, serial, text, integer, timestamp, index } from 'drizzle-orm/pg
// still settling, and adding a role should be a one-line schema change that `db:push` applies, not an
// ALTER TYPE. Postgres will not reject an unknown string — the TS type is the guard.
//
// 'Super Admin' is the server owner. There is exactly one, and it is not a role that gets handed out:
// see super-admin.ts, which still resolves the owner independently of this column.
// 'Super Admin' is the server owner, and this column is the ONLY thing that says so. isSuperAdmin()
// and getOwnerUser() both read it; there is no SUPER_ADMIN_EMAIL and no "first account wins" fallback
// any more. Two consequences worth holding on to: bootstrap must set it explicitly on the first
// account, and demoting the last Super Admin leaves the platform with no owner — nothing in the schema
// prevents that, so whatever eventually edits roles has to.
export const USER_ROLES = ['Super Admin', 'Admin', 'Member', 'Developer'] as const;
export type UserRole = (typeof USER_ROLES)[number];
+6
View File
@@ -29,12 +29,18 @@ export const bootstrapHandler: Handler = async function (ctx) {
const passwordHash = await argon2.hash(password);
// The role is what makes this account the owner — isSuperAdmin and getOwnerUser both read it, and
// nothing else confers it. Without this the first account would take the column's 'Member' default
// and the platform would come up with no owner at all: no vault, no agent identity, and the web
// origin locked to a Super Admin that does not exist. Bootstrap is gated on an empty user table
// above, so this cannot promote anyone but the first account.
const user = await createUser({
email,
password: passwordHash,
name: name.trim(),
username: validUsername,
status: 'Active',
role: 'Super Admin',
});
return ctx.json({ ok: true });
+20 -34
View File
@@ -1,38 +1,24 @@
import { getUsers, getUserByEmail } from 'officerdb';
// Identifies the platform owner ("Super Admin"). Two mechanisms, both supported:
// (b) explicit — SUPER_ADMIN_EMAIL in .env designates the owner by email.
// (a) automatic — if that's unset, the bootstrap/first account (lowest user id) is the owner.
// Resolved once and cached: the owner never changes at runtime (bootstrap is closed after user #1).
const { SUPER_ADMIN_EMAIL } = process.env;
let cachedId: number | null = null;
let resolved = false;
async function resolveSuperAdminId(): Promise<number | null> {
if (resolved) return cachedId;
if (SUPER_ADMIN_EMAIL) {
const user = await getUserByEmail(SUPER_ADMIN_EMAIL);
cachedId = user?.id ?? null;
} else {
const users = await getUsers();
cachedId = users.length ? users.reduce((min, u) => (u.id < min ? u.id : min), users[0]!.id) : null;
}
resolved = true;
return cachedId;
}
export async function getSuperAdminId(): Promise<number | null> {
try {
return await resolveSuperAdminId();
} catch {
// Transient DB error before the id is cached: stay unresolved (deny) and retry next call.
return null;
}
}
import { getUserById } from 'officerdb';
// Identifies the platform owner. The user's `role` column IS the answer — there is no second mechanism
// and no environment variable.
//
// This used to resolve the owner two other ways: SUPER_ADMIN_EMAIL from .env, falling back to the
// lowest user id. Both are gone. Three sources of truth for "who owns this server" (those two plus the
// role column) disagree the moment one is edited, and the failure is silent — an account quietly gains
// or loses the vault, the platform origin, and the identity the agent sidecar runs as.
//
// NOT CACHED, deliberately. The previous version cached an owner id and justified it with "the owner
// never changes at runtime (bootstrap is closed after user #1)". That stops being true the moment roles
// are editable, and a cache with no invalidation contract is a staleness bug waiting for whoever builds
// the role UI. This is one primary-key lookup on a request that has already verified a JWT.
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
if (!payload?.id) return false;
const adminId = await getSuperAdminId();
return adminId !== null && payload.id === adminId;
try {
const user = await getUserById(payload.id);
return user?.role === 'Super Admin';
} catch {
// A transient database error must never promote anyone. Deny, and let the next call retry.
return false;
}
}