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
12 KiB
Roles and teams
Status: plan, not built. Phase 0 landed 2026-08-23 (roles is a table). Everything below phase 0 is
design, written down so the work starts from a spec rather than from a conversation.
What this is
Officer began as one person's server and its authorization model says so: four role names in a TypeScript union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for a homelab and it cannot express a company, where the same person is an administrator of one area and an ordinary member of another.
Two things are wanted, and they are separable:
- Roles as data, so a role can be added without a deploy, and so each can eventually carry its own provisioning.
- Teams, so a user belongs to several and holds a different role in each.
Teams are optional and must stay optional. A homelab with family and friends on it may never make one; its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company uses both. Nothing below may make teams mandatory to reach a feature.
What already landed — phase 0, 2026-08-23
The roles table exists, seeded with Super Admin, Admin, Developer, Member, each flagged
isSystem because the platform's own code names it. provisioningScript is declared and null.
Nothing reads it yet. USER_ROLES is still the type, users.role is still plain text with no foreign
key, and authorize.ts is untouched. That was deliberate: the change is additive, so it cannot fail on
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
No foreign key, and this is the reason. db:push creates a table and adds a constraint in the same
run, so a foreign key from users.role onto an empty roles is violated by every existing account.
Verified against a scratch database: push reported the violation, created the table, skipped the
constraint, and exited 0. See prompts/deploy-commander.md → "Schema changes".
Decisions
D1. The owner is an identity. It is never a role.
OWNER_USER_ID — user 1, the account whose home is the home of the OS user the platform runs as.
Before phase 0, isSuperAdmin() answered user.role === 'Super Admin'. That was correct while roles were
a hardcoded union and nothing could write a new one. The moment roles are editable data it becomes a
privilege-escalation path: "add a role" and "become the owner" are the same feature, reachable from
whatever UI manages roles.
So ownership moves to user.id === OWNER_USER_ID, and getOwnerUser() becomes a primary-key lookup rather
than "the lowest id holding Super Admin". This is the load-bearing change in phase 1 and everything
else can wait behind it.
D2. Several Super Admins are allowed, and the role provisions sudo
Today create-user.ts and updateUserRoleHandler refuse to create a second Super Admin. That limit goes:
a company has an owner and several administrators, and a homelab owner may add one or never do.
Holding Super Admin provisions an OS account with sudo. Which means, plainly: that person is outside
Officer's permission system. role_permissions, the confined kernel containment and the
"execution is never grantable" rule bound nobody who can sudo cat any home on the box. And the grant is
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
they left behind.
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for the UI to say what it is rather than presenting it as one more row in a dropdown.
D3. Admin is app-wide authority with no operating-system privilege
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
read the CEO's files. It already exists and rolePermitsDevTools() already excludes it deliberately —
"administering the platform is not developing on it". This decision only writes that down.
D4. A role may be deleted, but not while anyone holds it
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change nobody asked for, and "absence denies" is the posture everywhere else in this system.
isSystem roles refuse both delete and rename, because the code names them by name:
rolePermitsDevTools() tests for Developer, bootstrap.ts writes Super Admin, users.role defaults to
Member. Renaming one is a code change that has not happened yet.
D5. Grants stay keyed on the role NAME, with ON UPDATE CASCADE
role_permissions.role and users.role both hold a name. Re-keying them onto a serial id would touch
authorize.ts — the file standing between a Member and a shell — to buy what Postgres gives for free: a
rename cascades to every user and every grant, atomically, with no code change.
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
appears in rolePermitsDevTools, in report text, and in whatever a plugin has written down.
D6. A team is a scope. Permissions UNION across a user's teams.
Admin in marketing and member in developers means marketing's admin level does not reach developers' files — but a user's effective permission for a given capability is the highest level any of their teams grants, plus their global role.
Union is the only coherent resolution, and it has a consequence worth stating: adding somebody to a team can only ever grant, never restrict. A team cannot be used to limit someone. If restriction is ever wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it should be refused rather than half-built.
D7. The global role remains, and answers a different question
Two questions, two mechanisms:
- "May this account administer the server at all?" — the global role. Super Admin, Admin.
- "What may this account do in this area?" — team membership with a role in that team.
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question has no natural team to ask it about. A homelab uses only the first and never makes a team.
D8. Per-role provisioning is a script the role carries
provisioningScript on the role row. Run when an account is granted that role; the reference implementation
is Developer, which today gets a Postgres role of its own through rolePermitsDevTools() — a special case
in code that should become a row.
The historical note matters for whoever builds it: that gate was originally for rootless Docker, and
rootless specifically. usermod -aG docker is root by a longer route —
docker run -v /:/host -it alpine chroot /host reads .env, every member's home and the wallet seed. Any
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
Phases
Phase 0 — roles are a table ✅ DONE 2026-08-23
Additive only. Nothing reads it.
Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
The load-bearing phase, and small.
isOwner(user)=user.id === OWNER_USER_ID.getOwnerUser()= lookup by that id.- Split the two meanings of today's
isSuperAdmin(): ownership, and holding the role. Every existing 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. - Drop the second-Super-Admin refusals in
create-user.tsandupdateUserRoleHandler. Remove the— KEPT, overruling this plan. It is redundant for the question it was written for:usersCHECK that pins user 1 toSuper AdminisOwneris an id comparison and never reads that column. But it still forbids user 1 being set toMemberwhile 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.
Phase 2 — the foreign key, and role CRUD
- Two releases, not one. The rows are on every host after phase 0; the constraint ships separately,
and its release plan carries the SQL explicitly per
prompts/deploy-commander.md. users.roleandrole_permissions.rolebecome foreign keys withON UPDATE CASCADE(D5).- Role CRUD: list with holder counts, create, rename, delete guarded by D4.
USER_ROLESstops being the source of truth.UserRolebecomesstring, and the picker reads the table. This is where the typed-union safety is lost — worth a runtime validator at the write path to replace what the compiler was doing.
Phase 3 — per-role provisioning
provisioningScript gets a runner, and rolePermitsDevTools() becomes a row rather than a branch. Needs
D8's warning in front of whoever writes the first script.
Phase 4 — teams
teams, andteam_members(team_id, user_id, role)— the role scoped to the team.team_permissions(team_id, role, permission, level), or a discriminator onrole_permissions. Decide by asking whether a team's roles are the same set as the global ones; probably yes, and probably that means one table with a nullableteam_id.authorize.tsresolves the union (D6): global role grants, plus the highest level any team grants. This is the one change in the whole plan that alters the resolver, so it wants tests before it wants code —permissions/authorize.test.tsis already pure and mockless, which is what makes that possible.- Teams UI, and the account screen showing which teams a user is in and as what.
Phase 5 — the thing that actually unblocks all of it
There is still no way to create a second account. createUser has one call site, auth/bootstrap.ts,
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
for accounts that cannot yet exist.
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and find nothing to test it with. It may be worth doing first.
What must not be built
- Deny rules. See D6. Teams grant; they never restrict.
- A role that confers ownership. See D1. There is exactly one owner and no role reaches it.
- Team-scoped
executioncapabilities. Tasks, items, desktop and browser run as the owner's OS user in the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no — the answer is aconfinedgrant with a Linux user, which is per-account by nature.
Verification
bunx tsgoclean. Note that on this machineearlyoomSIGTERMs thebunxwrapper around 15s; run the binary directly.permissions/authorize.test.tsis the primary guard, and it uses real registry keys deliberately so a rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.bun run docs:check— the permission count claim inCLAUDE.mdmoves whenever the registry does.- Any schema phase: verify by query per host, never by
db:push's exit code.
Open questions
- Does a team have its own capabilities, or only its own levels? Whether
team_permissionscan grant a capability the account's global role does not have at all. Union says yes; that may be more than wanted. - Who may create a team? Owner only, or any Admin. Bears on whether a team is an administrative object or a self-service one.
- Does a team own anything? A team folder, a team dashboard. The answer decides whether teams are purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
- What happens to a user's team memberships when a role is deleted? D4 refuses to delete a role anyone holds globally; the same question exists per team and is not answered here.