ADR-0015: RBAC roles for halo-server
ADR-0015: RBAC roles for halo-server
Section titled “ADR-0015: RBAC roles for halo-server”Context and problem statement
Section titled “Context and problem statement”halo-server had two kinds of user: a developer (self-service) and an admin (identity.adminGroups), and every privileged route checked the same admin flag. Regulated organisations need segregation of duties: the people who can kill an experiment should not automatically approve access requests, and the people who review the audit log should not be able to change what it records. The single flag forces them to make everyone who needs any of that an admin. How does halo-server grant narrower authority without making the authorization surface harder to review?
Decision drivers
Section titled “Decision drivers”- Separation of duties a compliance review can check: who can operate, approve, audit and administer are separate grants.
- One place to review: an auditor reading the code should see every route and what it needs in one table.
- Deny by default: a privileged route added later without a decision must not be reachable.
- Every refusal is evidence: an authenticated user probing for authority leaves an audit trail.
- Existing policy repos keep working:
adminGroupskeeps meaning admin. - The group-to-role mapping is reviewed like any other policy change (PR in the policy repo), not set by a flag on one server.
Considered options
Section titled “Considered options”- Fixed roles mapped from IdP groups in policy (
identity.roles), permissions per route in one table. - Per-route group lists in policy. Maximum flexibility, but every new route needs a policy change in every org, and nobody can say what “an operator” is.
- External policy engine (OPA/Cedar). Expressive, but a new dependency and trust boundary for five roles, and ADR-0007 already chose Go over Rego for guardrails.
Decision outcome
Section titled “Decision outcome”Chosen: option 1.
Roles and the permissions they grant (internal/server/rbac.go, rolePerms):
| Role | Permissions | Can |
|---|---|---|
viewer |
read | Fleet, devices, releases, rollouts, experiments, toggles, kill list, policy, other users’ assignments, everyone’s view in the console |
operator |
read, operate | Viewer plus kill and unkill (experiments and toggles), experiment status PRs (pause, resume, conclude) |
approver |
read, approve | Viewer plus approve and deny access requests; sees every request |
auditor |
audit | Read and page through (export) the audit log. Nothing else: map the group to viewer too if auditors should see the fleet |
admin |
all of the above, admin | Device and session revocation, toggle change proposals, launcher setup details |
Every logged-in user keeps self-service (/me, own devices, catalog, launch, own requests, own assignments) without a role.
- Mapping.
identity.roles.{viewer,operator,approver,auditor,admin}lists IdP groups. A user holds every role any of their groups maps to.identity.adminGroupsgrantsadminexactly as before; it is not deprecated, androles.adminis equivalent. Strict YAML decoding rejects an unknown role name, so a typo is a load error rather than a silent empty grant. Roles are recomputed from current policy on every request, so removing a group from a role takes effect on the next request. Adding one applies at the user’s next login, because the session keeps only the groups policy referred to when it was issued. - Enforcement.
routePermsmaps every cookie-authenticated route pattern to one permission. Routes register throughs.route, which looks the pattern up; a pattern with no entry is refused to everyone, admins included. Bearer-token and public routes (fleet reports, gateway, enrollment, OIDC) are outside the table and listed in the test.TestRoutePermsCoverMuxwalks every registered pattern and fails if one is neither public nor mapped, if a mapped route bypassess.route, or if the table names a route that no longer exists.TestRolePermissionMatrixrequests every mapped route as every role. - Two in-handler checks stay because they depend on the request, not the route:
whoamifor another user needsread, and listing everyone’s requests needsapprove. Both call the samedeny. - Audit. Every 403 for lack of permission appends
authz.denywith the actor, path, route pattern, method, needed permission and the caller’s roles. It is best effort: the request is refused whether or not the append succeeds. - Console.
GET /api/v1/meandGET /api/v1/capabilitiesreturn the caller’srolesandpermissions; the console shows only the pages and buttons those allow. The server enforces regardless.adminstays in/mefor older clients. - Demo mode.
--dev-insecure-rolessets the roles of--dev-insecure-user;--dev-insecure-adminstill addsadmin.
Consequences
Section titled “Consequences”- Good: segregation of duties is a policy-repo diff; a reviewer sees the group-to-role mapping and the route table without reading handlers.
- Good: a new privileged route cannot ship reachable by accident: the test fails, and at runtime it is denied.
- Good: denied attempts are in the hash-chained audit log alongside the actions themselves.
- Bad: roles are fixed in code. An org that needs, say, “operator without unkill” cannot express it; that needs a new permission and a code change.
- Bad: an authenticated user can grow the audit log by repeating denied requests. Each entry names them; rate limiting is left to the reverse proxy.
- Neutral: permissions are coarse (one per route). Per-experiment or per-ring scoping is not modelled.
More information
Section titled “More information”internal/server/rbac.go(rolePerms,routePerms,route,deny),internal/policy/types.go(RoleGroups),schemas/halos.schema.json(identity.roles).- Tests:
internal/server/rbac_test.go,internal/policy/roles_test.go. - Security model, production deployment.