feat(security): named RBAC roles (phase 1) - #83
Conversation
Phase 1 of design/rbac-roles-and-host-scoping.md. A role is a reusable bundle of section grants, each section independently read-only or writable — finer-grained than the account-level flag, per decision D2. checkAccess now resolves effective grants instead of reading u.Sections directly: the union of the account's roles and its own section list, capped by the read-only flag, with app-wide disabled sections removed last. It is still the single gate for both REST and MCP, and MCP token narrowing is untouched (it runs before this). Backwards compatible by construction: a user with no roles resolves exactly as before, which a test asserts directly. Grants are computed per request, so revoking a role is immediate rather than waiting for a session to expire. Two immutable built-ins — Viewer (all sections, read-only) and Operator (day -to-day work, deliberately not hosts/registries/audit) — with Duplicate to customise, mirroring project templates. Seeding never overwrites an existing row, so an edited description survives a restart. Role management is admin-only: "roles" maps to the __admin section, asserted for every route shape so one can't fall through to ungated. Fails closed if grants can't be computed. 25 tests including 5 new pentests: built-ins immutable via store and HTTP, a non-admin with every section still denied, read-only account survives a writable role, disabled section beats a role, and revocation takes effect at once. The role-management UI and LDAP group→role mapping follow next; roles are configurable over the API only for now.
There was a problem hiding this comment.
Pull request overview
Implements phase 1 of the RBAC “named roles” design: adds reusable roles with per-section write grants, keeps the account-level read-only cap, and updates authorization to compute effective grants (union of per-user sections + assigned roles, minus disabled sections). Adds role CRUD API (admin-only), seeds built-in Viewer/Operator roles, and updates docs/changelog accordingly.
Changes:
- Add
roles,role_sections, anduser_rolestables plus built-in role seeding (Viewer/Operator) and store APIs for role CRUD + effective grant computation. - Update access enforcement (
checkAccess) to use effective grants and differentiate “account read-only” vs “section read-only”. - Add admin-only role-management HTTP endpoints + tests/pentests; extend user APIs to return/accept role assignments.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| NEXT.md | Marks RBAC phase 1 (named roles) as shipped and notes follow-ups. |
| internal/store/store.go | Adds new role-related tables and triggers seeding during migration. |
| internal/store/roles.go | Implements role CRUD, role assignment, and effective grant computation; seeds built-ins. |
| internal/store/roles_test.go | Adds unit tests and store-level pentests for roles + effective grants semantics. |
| internal/api/user_handlers.go | Extends user list/create/update to include role IDs and effective sections. |
| internal/api/server.go | Registers /api/roles… routes (admin-only). |
| internal/api/role_pentest_test.go | Adds API-level pentests covering admin-only routes, immutability of built-ins, read-only caps, disabled sections, and revocation immediacy. |
| internal/api/role_handlers.go | Implements role CRUD + duplicate endpoints and auditing. |
| internal/api/access_middleware.go | Updates routing-to-section mapping and checkAccess to use effective grants. |
| docs/users.md | Documents named roles, built-ins, precedence rules, and admin-only role management. |
| CHANGELOG.md | Adds user-facing release notes for named RBAC roles (phase 1). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if _, err := s.db.ExecContext(ctx, `DELETE FROM user_roles WHERE role_id = ?`, id); err != nil { | ||
| return err | ||
| } | ||
| _, err = s.db.ExecContext(ctx, `DELETE FROM roles WHERE id = ?`, id) | ||
| return err |
| r, err := s.RoleByID(ctx, id) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| out = append(out, *r) |
| func sectionSummary(sections []store.RoleSection) string { | ||
| out := "" | ||
| for i, rs := range sections { | ||
| if i > 0 { | ||
| out += ", " |
| // Find a free name rather than failing on the first collision. | ||
| name := src.Name + " copy" | ||
| for i := 2; ; i++ { | ||
| if _, err := s.roleByName(r, name); errors.Is(err, store.ErrNotFound) { | ||
| break | ||
| } | ||
| name = src.Name + " copy " + strconv.Itoa(i) | ||
| if i > 50 { | ||
| writeErr(w, http.StatusConflict, "too many copies of that role") | ||
| return | ||
| } | ||
| } |
| if _, err := s.RoleByID(ctx, id); err != nil { | ||
| continue // unknown role: ignore | ||
| } |
Found reviewing this branch, along with two smaller issues.
An empty stored token scope means "inherit the owner's rights", so a request
whose sections all got filtered out was persisted as an unrestricted token —
asking for a narrower token returned a broader one. Now refused. Scopes are
also filtered against EFFECTIVE sections, so access granted through a role
can be scoped to; matching against the per-account list alone dropped those
and fed the same widening path. The widening was reachable before roles
existed, by requesting only ungranted sections.
replaceRoleSections is now transactional: the delete-then-insert was visible
half-applied to a concurrent request, and a failure partway left a role with
an arbitrary subset of its grants. It fails safe (fewer grants), hence low
severity, but it shouldn't be observable at all.
userBody.RoleIDs becomes a pointer so an ABSENT field differs from an empty
list. The existing Users UI sends {role, readOnly, sections} and nothing
else, so editing any user in the browser would have silently stripped their
roles.
Also collapses EffectiveGrants' per-role round trips into one join — it runs
on every gated request, so it was O(roles) queries per call.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
internal/api/mcp_token_handlers.go:123
- The new "filtered-to-nothing" guard only triggers when
cleanSectionsyields a non-empty slice (len(requested) > 0). If the client sends a non-emptysectionslist made entirely of invalid section keys,cleanSectionsdrops them all and the request falls through tosections == nil/empty, which means an unscoped token (inherits all owner rights). For narrowing tokens, any non-emptysectionspayload that results in an empty validated+filtered scope should be refused.
// An explicit scope that filters down to nothing must NOT fall through to
// "empty = inherit everything" — the caller asked to narrow, and silently
// handing back an unrestricted token would widen their reach instead.
if len(requested) > 0 && len(sections) == 0 {
writeErr(w, http.StatusBadRequest,
"none of the requested sections are granted to your account, so this token would not be scoped to anything")
return
internal/store/roles.go:279
DeleteRoledeletes fromuser_rolesand then deletes the role row, but it never deletesrole_sections. That leaves orphaned grants rows behind indefinitely. Also, without a transaction, a failure deleting fromroleswould permanently strip assignments while leaving the role (and its sections) in place.
// DeleteRole removes a user-defined role and any assignments of it. Built-ins are
// refused.
func (s *Store) DeleteRole(ctx context.Context, id int64) error {
existing, err := s.RoleByID(ctx, id)
if err != nil {
return err
}
if existing.Builtin {
return ErrBuiltinRole
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM user_roles WHERE role_id = ?`, id); err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `DELETE FROM roles WHERE id = ?`, id)
return err
internal/store/roles.go:321
SetUserRolesis not transactional: it deletes all existing assignments, then inserts the new ones. If an insert fails part-way through (DB error, context cancellation), the user can be left with a partially-applied role set. It also does an extraRoleByIDquery per role ID. Wrapping the rewrite in a transaction and using anEXISTSinsert keeps the operation atomic and avoids N+1 queries.
func (s *Store) SetUserRoles(ctx context.Context, userID int64, roleIDs []int64) error {
if _, err := s.db.ExecContext(ctx, `DELETE FROM user_roles WHERE user_id = ?`, userID); err != nil {
return err
}
seen := map[int64]bool{}
for _, id := range roleIDs {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
if _, err := s.RoleByID(ctx, id); err != nil {
continue // unknown role: ignore
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)`, userID, id); err != nil {
return err
}
}
return nil
internal/api/role_handlers.go:177
- The duplicate-name probe calls
s.roleByNameinside a loop;roleByNamecallsListRoles, which loads every role and its sections (N+1 queries inside the store) each time. On a transient DB error,roleByNamereturns a non-ErrNotFound error which is treated as a name collision, eventually surfacing as "too many copies" instead of 500. Consider reading the existing role names once and handling list errors explicitly.
// Find a free name rather than failing on the first collision.
name := src.Name + " copy"
for i := 2; ; i++ {
if _, err := s.roleByName(r, name); errors.Is(err, store.ErrNotFound) {
break
}
name = src.Name + " copy " + strconv.Itoa(i)
if i > 50 {
writeErr(w, http.StatusConflict, "too many copies of that role")
return
}
}
internal/api/mcp_token_handlers.go:104
- There is no regression test covering the case where the client provides a non-empty
sectionspayload but all entries are invalid section keys. With the current semantics (empty stored scope = inherit), this is an important narrowing-safety case to pin with a pentest: it should be rejected (4xx) and must not persist an unscoped token.
This issue also appears on line 117 of the same file.
// Section narrowing: a token may only reference sections the owner actually
// has (admins may use any valid section). Empty = inherit all of the owner's.
requested := cleanSections(b.Sections)
sections := requested
if !u.IsAdmin() {
// Effective sections, not u.Sections: access may come from an assigned
// role, and a token must be scopeable to those too.
effective, err := s.store.EffectiveSections(r.Context(), u)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not determine your permissions")
Summary
Phase 1 of design/rbac-roles-and-host-scoping.md,
implementing decisions D2 (per-section
write), D5 (built-in Viewer/Operator withDuplicate) and part of D1. Per-host scoping is phase 2 and is not here.
A role is a reusable bundle of section grants — an admin no longer ticks
thirteen checkboxes per account. Each section in a role is independently
read-only or writable, which is finer-grained than the account-level flag.
checkAccessnow resolves effective grants instead of readingu.Sectionsdirectly: the union of the account's roles and its own section list, capped by the
read-only flag, with app-wide disabled sections removed last. It remains the single
gate for both REST and MCP, and MCP token narrowing is untouched — it runs
before this and is additive.
Type of change
Checklist
go test -short ./...andgo vet ./...passgofmtgate is clean (gofmt -l $(git ls-files '*.go')after staging)web/dist— N/A (nothing underweb/srcchanged)docs/and added aCHANGELOG.mdentry for user-facing changesNotes for reviewers
Start with
internal/api/access_middleware.go. It's the only behaviouralchange to an existing security path; everything else is additive. The precedence
is explicit and each rule has a test:
adminbypasses (unchanged).lift it. This is what makes D2's migration mapping safe.
a feature an admin turned off.
It also fails closed: if grants can't be computed, access is denied rather
than falling through.
Backwards compatibility is asserted, not assumed.
TestEffectiveGrants_NoRolesMatchesLegacyBehaviourpins that a user with no rolesresolves exactly as before, and the pre-existing suite passes untouched — which is
the real evidence, since it already covered the old semantics.
25 tests, 5 of them new pentests. The ones worth reading:
ErrBuiltinRole) and HTTP (403) —because Viewer silently gaining write would escalate everyone holding it;
__admin, so nocombination of grants reaches role management;
sectionForPathis asserted__adminfor every/api/roles…shape, since onefalling through to
""would make role editing reachable by any authenticateduser — the single worst failure mode here;
*store.Uservalue deliberately, to prove grants aren't baked into it.Design choices worth flagging:
adminstays a string on the user, not a row inroles. It's the lockoutsafety valve; making it data invites a migration that locks the operator out of
their own instance.
survives a restart and grants can't be silently reset on upgrade.
permission sitting in the database is worse than a validation error.
Deliberately not here
this PR is complete and testable but not yet usable from the browser. UI next.
ships with it rather than dead.
Both are recorded in NEXT.md so they aren't lost.