Files
platform/docs/roles-and-teams.md
T
pastilhasandClaude Opus 5 5c6aea4ffc teams: the four open questions are answered
A team owns a shared folder. Any Admin may create one. A team may grant a
capability the member's role lacks, capped so the creator cannot attach
anything they do not hold themselves. Deleting a role is refused while anyone
holds it in a team, same as globally.

Dashboards deferred deliberately: they need editor-versus-viewer, which is a
permission shape nothing here has, since every grant today is read-or-write over
a whole capability.

Also records two things phase 4 must not get wrong. The folder boundary is a
Linux group, not the grant — every role already holds files at write, so the
capability was never what kept teams apart. And the grant cache is keyed on role
today; teams make it per-user, which is easy to lose silently.

Plus the quota model and its prerequisite: enforcement is the kernel's, because
members have terminal and can write past anything the platform counts. Neither
officer-dev nor alpha can carry filesystem quotas today and neither can be fixed
while running. edge-pertento is where it matters — the first real multi-user
host — and it should be checked before it gains accounts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 21:56:39 +00:00

16 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.

  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 — 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.

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 — account creation — DONE, and it was done before this phase was written

POST /api/users is owner-gated and live, with a form under Settings → User Management: same password validator as bootstrap, a role checked against the roles table, Super Admin refused outright, an optional inbound SSH key, and Linux provisioning with POST /users/:id/provision-linux to retry it. Alongside it: role change, password reset, delete.

It shipped in 69a31051. This section spent days telling readers it did not exist and sequencing work around building it — and on 2026-08-23 it produced a concrete cost, when a recommendation to "do phase 5 first" was made from this text and withdrawn only because someone opened api/users/ to write the spec.

The general lesson, since it is the second time this exact shape has bitten: bun run docs:check compares numbers, so counts stay honest while prose rots quietly. A phase marked as not built is worth verifying against the tree before it is planned around, not because the plan was careless but because a plan is written once and the code moves every day.


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.

Teams — decided 2026-08-23

The four open questions were answered by the owner. Recorded here so phase 4 is implementation rather than another round of design.

A team owns a shared folder. Not only an authorization grouping — storage is the part that was actually wanted. Team dashboards are a probable later addition and were explicitly deferred, because they need a permission shape nothing here has: people who can edit a dashboard's STRUCTURE versus people who only see what it renders. Every grant today is read-or-write over a whole capability, so that is a new model rather than a new level. Build the folder; leave room for the dashboards.

Any Admin may create a team, not the owner alone. Teams are self-service for people already trusted to administer the platform. This is what makes the quota section below necessary rather than optional: an Admin creating a team is an Admin allocating disk.

A team may grant a capability the member's own role lacks — the union, per D6 — with one rule on top: whoever creates the team can only attach capabilities they hold themselves. Escalation is capped at the creator's own reach rather than the whole registry. execution and admin stay ungrantable regardless, and confined still resolves to nothing for an account with no Linux user.

Deleting a role is refused while anyone holds it, in a team or globally. The same rule D4 already applies to global holders, extended rather than special-cased; the refusal should name the teams.

Two things phase 4 must not get wrong

The shared folder is enforced by the kernel, not by the grant. Every role already holds files at write — they are the seeded confined defaults — so the capability is not what keeps one team out of another's folder. A Linux group per team is. The grant says whether someone can use the files surface at all; group membership says which folders they reach. Both are required and they are not substitutes.

The grant cache is keyed on ROLE today, deliberately: "at most one entry per role, and a new account needs no warm-up" (permissions/authorize.ts). Teams end that — effective permissions become role plus memberships, which is per-user. That property is easy to lose without noticing and awkward to recover, so it should be a decision in the implementation rather than a side effect.

Quotas — the model, and the prerequisite nobody can skip

The owner declares platform-level defaults and maxima: per user, and per team. Admins allocate inside them. A change re-applies at the Linux level through POST /users/:id/provision-linux, which already exists as a retry path and becomes "re-apply current policy to this account".

Enforcement is the kernel's job, not the platform's. Members hold terminal at write and have their own Linux accounts, so a quota the platform tracks by counting bytes through the file browser is decorative — a member opens a terminal or SSHes in and writes past it. Only filesystem quotas bind.

Which means a filesystem that can carry them. Checked on 2026-08-23: neither officer-dev nor alpha can. Both are ext4 with homes on the root filesystem, no usrquota/grpquota/prjquota mount options, and neither has the ext4 quota or project feature. Enabling project needs tune2fs -O project on an UNMOUNTED filesystem, and / cannot be unmounted on a running server — so it is a rescue boot, not a config change.

The cheap moment is when a host is built, by giving the Linux homes their own filesystem with quota features from the start. Retrofitting a live host is expensive and gets more so with every account on it.

Where this actually matters is edge-pertento — the first real multi-user deployment, and the only one that will have accounts that are not the owner's. officer-dev is a development machine and will not be multi-user; alpha is the owner's own. Check edge-pertento's disk before it gains more accounts.

On a host that cannot enforce, the platform says so — "policy recorded, not enforceable here" — rather than storing numbers that do nothing. A feature that silently does nothing is worse than one that admits it cannot.

Open questions

None outstanding for phase 4. The four that were here are answered above. holds globally; the same question exists per team and is not answered here.