the owner is an identity, not a role
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
This commit is contained in:
@@ -77,14 +77,19 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
||||
}
|
||||
|
||||
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
|
||||
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
|
||||
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
|
||||
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
|
||||
// server over is a deliberate act, not a dropdown.
|
||||
if (id !== OWNER_USER_ID && role === 'Super Admin') {
|
||||
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
|
||||
}
|
||||
/*
|
||||
A second Super Admin is allowed since 2026-08-23, and the refusal that used to live here is gone.
|
||||
|
||||
It existed because `getOwnerUser()` resolved the owner BY that role, so two holders made "who owns this
|
||||
server" a question the query plan answered — and that answer decided the agent sidecar's identity, vault
|
||||
access and which origin is privileged. Ownership is now `OWNER_USER_ID`, so the role no longer carries
|
||||
it and several accounts may hold it.
|
||||
|
||||
What the role does carry is real and worth saying out loud in the UI rather than here: it provisions an
|
||||
OS account with sudo. That puts the holder outside Officer's permission system entirely and cannot be
|
||||
undone by revoking the role afterwards. Granting it is handing over the machine — which is the operator's
|
||||
decision to make, and no longer one this endpoint refuses on their behalf.
|
||||
*/
|
||||
|
||||
const existing = await getUserById(id);
|
||||
if (!existing) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
Reference in New Issue
Block a user