diff --git a/src/databases/officer_db/src/queries/auth.ts b/src/databases/officer_db/src/queries/auth.ts index 3422bf07..4385b632 100644 --- a/src/databases/officer_db/src/queries/auth.ts +++ b/src/databases/officer_db/src/queries/auth.ts @@ -19,11 +19,16 @@ export async function getUserByEmail(email: string): Promise { - 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; } diff --git a/src/databases/officer_db/src/schema/auth.ts b/src/databases/officer_db/src/schema/auth.ts index b6bb32cd..85ca075f 100644 --- a/src/databases/officer_db/src/schema/auth.ts +++ b/src/databases/officer_db/src/schema/auth.ts @@ -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]; diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index c576f1cb..f8604689 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -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 }); diff --git a/src/servers/super-admin.ts b/src/servers/super-admin.ts index 25de500f..626433c4 100644 --- a/src/servers/super-admin.ts +++ b/src/servers/super-admin.ts @@ -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 { - 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 { - 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 { 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; + } }