role CRUD, with a guard the spec did not ask for

Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each
returning a discriminated result rather than throwing, so a router can say which
rule refused and why instead of parsing an error string. Written by a Sonnet
subagent against the plan; reviewed, exercised against the live database, and
amended here.

The amendment is the reason a review was worth doing. renameRole changes
roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as
its own release — so a rename would leave every holder pointing at a name that no
longer exists. It is unreachable today only by accident: manage-users.ts validates
against the USER_ROLES union, so a runtime-created role cannot be assigned and a
non-system role has no holders. That evaporates the moment part 4 makes UserRole a
plain string, and parts 2 and 4 could land in either order.

So rename now refuses while anybody holds it, sharing one holder-count definition
with delete so the two cannot disagree about what "in use" means. It comes out when
the cascade lands, and then the cascade does the job properly by moving holders
rather than refusing.

Adding that reason made the router's switch non-exhaustive, which the typechecker
caught — the discriminated-result shape earning its keep on its first change.

System roles refuse both rename and delete: the platform's own code names them by
string, so renaming one is a code change that has not happened. Delete never
reassigns holders to a fallback, because that is a permission change nobody asked
for.

Exercised end to end: duplicate, empty name, system rename, system delete, a real
rename, a rename onto an existing name, delete, and delete of something absent.
The four seeded roles survive with the owner counted against Super Admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-23 15:09:52 +00:00
co-authored by Claude Opus 5
parent 1e268d553d
commit 04c1a6bfc9
6 changed files with 276 additions and 3 deletions
+16
View File
@@ -461,6 +461,22 @@ const CORE_REGISTRY: Permission[] = [
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
// platform no longer knows this permission exists, which is the entire point.
{
key: 'roles',
label: 'Roles',
description: 'Create, rename and delete the roles this server grants',
// Admin, and for the same reason `user-admin` beside it is: a role is not a member's own data, it is
// policy that governs every account holding it, so there is no level of "read" that makes creating or
// deleting one a feature to grant. `registry.test.ts` asserts admin can never be handed out — this
// entry is what makes that assertion mean something for roles specifically.
//
// Separate from `user-admin` rather than folded into it: that permission's `/users` is the account
// list and the grants a role holds (`/users/permissions`); this one is the roles TABLE itself — the
// name, the description, whether it is system-named. Two routers, two questions, so a boot-time check
// that a mounted prefix has exactly one owner stays meaningful for both.
kind: 'admin',
api: ['/roles'],
},
];
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────