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];