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
283 lines
16 KiB
Markdown
283 lines
16 KiB
Markdown
# 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.
|