diff --git a/CLAUDE.md b/CLAUDE.md index 309facb8..6b59e6a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -477,6 +477,9 @@ explaining why it was safe. owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level, never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to remember, and the credential-resolution work that has to land first +- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one + change that has to land first — the owner becomes an identity rather than whoever holds a role name, + because otherwise "add a role" and "become the owner" are the same feature - `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the web UI to more than one language. What exists and what it cannot do yet, why the server should return error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key diff --git a/docs/roles-and-teams.md b/docs/roles-and-teams.md new file mode 100644 index 00000000..d6906475 --- /dev/null +++ b/docs/roles-and-teams.md @@ -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.