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:
2026-08-23 14:49:20 +00:00
co-authored by Claude Opus 5
parent 06d5c64de3
commit 44ee444cac
6 changed files with 78 additions and 22 deletions
+11 -2
View File
@@ -131,7 +131,7 @@ provisioning script that adds a user to a privileged group is granting more than
Additive only. Nothing reads it.
### Phase 1 — the owner stops being a role
### Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
The load-bearing phase, and small.
@@ -140,7 +140,16 @@ The load-bearing phase, and small.
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
"owner or Super Admin" is a separate, reviewable change per route.
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
4. ~~Remove the `users` CHECK that pins user 1 to `Super Admin`~~ — **KEPT, overruling this plan.** It is
redundant for the question it was written for: `isOwner` is an id comparison and never reads that column.
But it still forbids user 1 being set to `Member` while remaining the owner, which is a state with no
benefit and a confusing display. A constraint that costs nothing and forbids a nonsense state is worth
keeping after its original reason has gone.
**One asymmetry introduced deliberately.** An existing account can now be promoted to `Super Admin`;
creating one directly as `Super Admin` is still refused by `create-user.ts`. Promotion happens to an account
somebody has looked at, from a screen showing who they are. Creation is a form filled in once, and the role
that provisions sudo should not be a dropdown default reached by accident. Create, then promote.
Reviewable on its own, and nothing downstream can start until ownership is an identity.