Files
platform/docs/roles-and-teams.md
T
pastilhasandClaude Opus 5 6d8709c05a plan: roles as data, teams as scopes, and the owner as an identity
Design for tomorrow, written down so the work starts from a spec. Phase 0 is
already built; everything after it is plan.

The load-bearing decision is the smallest one. Ownership is answered today by
users.role === 'Super Admin', which was fine while roles were a hardcoded union
and nothing could write a new one — and becomes a privilege-escalation path the
moment roles are editable data, because then adding a role and becoming the owner
are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every
existing call site keeps meaning owner, and nothing downstream starts until it
lands.

Teams union rather than restrict, which is the only coherent resolution and has a
consequence worth stating: a team can only ever grant. Deny rules are named under
what must not be built, along with a role that confers ownership and team-scoped
execution capabilities.

And the thing a reader would otherwise discover at the end of phase 4: there is
still no way to create a second account, so this is a permission model for
accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged
because it may be worth doing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:40:27 +00:00

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

The load-bearing phase, and small.

  1. isOwner(user) = user.id === OWNER_USER_ID. getOwnerUser() = lookup by that id.
  2. 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.
  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.

Reviewable on its own, and nothing downstream can start until ownership is an identity.

Phase 2 — the foreign key, and role CRUD

  1. 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.
  2. users.role and role_permissions.role become foreign keys with ON UPDATE CASCADE (D5).
  3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
  4. USER_ROLES stops being the source of truth. UserRole becomes string, 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

  1. teams, and team_members(team_id, user_id, role) — the role scoped to the team.
  2. team_permissions(team_id, role, permission, level), or a discriminator on role_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 nullable team_id.
  3. authorize.ts resolves 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.ts is already pure and mockless, which is what makes that possible.
  4. 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 execution capabilities. 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 a confined grant with a Linux user, which is per-account by nature.

Verification

  • bunx tsgo clean. Note that on this machine earlyoom SIGTERMs the bunx wrapper around 15s; run the binary directly.
  • permissions/authorize.test.ts is 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 in CLAUDE.md moves whenever the registry does.
  • Any schema phase: verify by query per host, never by db:push's exit code.

Open questions

  1. Does a team have its own capabilities, or only its own levels? Whether team_permissions can grant a capability the account's global role does not have at all. Union says yes; that may be more than wanted.
  2. Who may create a team? Owner only, or any Admin. Bears on whether a team is an administrative object or a self-service one.
  3. 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.
  4. 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.