Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
45 lines
2.4 KiB
TypeScript
45 lines
2.4 KiB
TypeScript
import { getUserById, OWNER_USER_ID } 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> {
|
|
return isOwner(payload);
|
|
}
|
|
|
|
/**
|
|
* The owner is an IDENTITY, not a role. `OWNER_USER_ID` and nothing else.
|
|
*
|
|
* This asked `user.role === 'Super Admin'` until 2026-08-23, which was correct while roles were a hardcoded
|
|
* TypeScript union and nothing could write a new one. The moment roles became a table it became a
|
|
* privilege-escalation path: "add a role" and "become the owner" are the same feature, reachable from
|
|
* whatever UI manages roles.
|
|
*
|
|
* So the question moved to one nothing in the product can answer for itself. `Super Admin` is still a role
|
|
* and several accounts may hold it — it provisions an OS account with sudo, which is its own decision — but
|
|
* holding it is not being the owner and no role grants that.
|
|
*
|
|
* Kept as a database read rather than a bare comparison, deliberately. A deleted or blocked account must not
|
|
* still be the owner because its id happens to match, and this is one primary-key lookup on a request that
|
|
* has already verified a JWT.
|
|
*/
|
|
export async function isOwner(payload: { id?: number } | null | undefined): Promise<boolean> {
|
|
if (payload?.id !== OWNER_USER_ID) return false;
|
|
try {
|
|
const user = await getUserById(OWNER_USER_ID);
|
|
return !!user;
|
|
} catch {
|
|
// A transient database error must never promote anyone. Deny, and let the next call retry.
|
|
return false;
|
|
}
|
|
}
|