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>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user