Read plane, write plane, admin plane#
Wheelhouse has three roles — viewer, operator, admin — and they nest: an admin can do everything an operator can, an operator everything a viewer can. Every route in the agent names the minimum role it requires, in one place, so the whole access model can be read at once. The check is server-side on every request. The web UI's greying-out of what your role cannot do is a courtesy on top of that, not the gate.
The three planes#
| Plane | Role | Contains |
|---|---|---|
| Read | viewer | Every GET: system, interfaces, routes, firewall, NAT, QoS, sessions, DHCP, DNS, VPN, containers, certificates, logs, the audit log, the commit history and its diffs, the staged working set, the telemetry stream, licence state. |
| Write | operator | Stage, un-stage, commit, commit-confirm, discard, direct configure, rollback, save and load a configuration, reconcile, app install and lifecycle, traceroute, the inline-IPS plan. |
| Admin | admin | Accounts, API tokens, agent settings, the licence key, power and boot images, the single-sign-on view, and the unredacted configuration download. |
A handful of routes need no authentication at all, because something has to work
before anyone is signed in: GET /health (liveness, used by the installer's health
check and by a load balancer), GET /api/auth/status (what the login screen needs to
draw itself), and the three single-sign-on routes, where the state parameter is what
makes a callback honourable and no session exists until the ID token verifies.
Re-derive the whole table from the tree at any time:
grep -o 'mux.HandleFunc("[A-Z]* [^"]*"' agent/main.goEach line in agent/main.go's routes() is wrapped in exactly
one of readOnly, writeable or adminOnly — the three tiers defined in
agent/authhttp.go — so the wrapper is the answer for
that route.
Four rules that are not obvious from the table#
A write also needs a licence. The write plane is wrapped twice:
writeable(requireLicense(h)). Reads never need one. See
What the licence gates.
A configuration read is redacted below admin. GET /api/config,
/api/config/commands, /api/history/diff, /api/drift and the VPN and PKI reads
pass through redactSecrets(RoleAdmin, …), which blanks private keys, pre-shared
secrets and password hashes for anyone below admin
(agent/security.go). It is a wrapper rather than a line in
each handler because those endpoints reach the same secrets by different code paths.
The staged working set is redacted below operator, not admin. An operator who
staged a key typed it, and a preview showing them [redacted] for their own change is
not a preview. A viewer has no such claim on it.
Two routes sit off the obvious grid, for stated reasons.
GET /api/reconcile/fileneeds operator, not viewer: it reads a file out of the agent's data directory and renders it as operations.POST /api/diagnostics/tracerouteneeds operator but no licence: it runs a command on the router rather than changing configuration.POST /api/ids/ips/planis the same shape — it renders the operations to enable inline IPS without applying them.
GET /metrics requires the viewer role by default and is open to anyone when the
agent was started with --metrics-public.
What happens when a role is not enough#
The request is refused with 403 and a message naming the role it needed — this
action requires the operator role — and the refusal itself is recorded as
denied <method> <path> with the actor, their role and their address
(agent/authhttp.go, requireRole). A denial is a fact
worth keeping; it is often the first sign of a credential in the wrong hands.
An unauthenticated or expired request is 401 instead, and the browser client handles a 401 globally by returning to the sign-in screen rather than leaving half-loaded pages showing stale data.
What the UI does with your role#
useCanWrite() tells a page whether the principal may change things, and write
actions use a gated button that disables with a reason rather than hiding
(docs/ui.md). That is a deliberate choice: an operator should be
able to see that a capability exists and that their role is what is withholding it.
The Commit Bar does the same, replacing its buttons with Your role cannot commit
changes.
None of this is security. The agent enforces the same gate on every route, and a client that skips the UI entirely is refused identically.
What roles do not do#
Roles also do not cover the router's own accounts. system login on the router is a
separate set of users with separate credentials; a Wheelhouse operator is not a shell
user, and vice versa.
Assigning a role#
An admin sets a role when creating an account, or changes it with
PATCH /api/admin/users/{name}. An API token carries a role of its own, chosen when
it is created. With single sign-on, a role can be mapped from the identity provider's
group claim — --oidc-admin-groups and --oidc-operator-groups, with
--oidc-default-role (default viewer) for an identity in neither. Group mapping is
opt-in; with no groups configured, provisioned accounts get the default role.
See also#
- Accounts, sessions and tokens — who the principal is.
- What the licence gates — the second gate on every write.
- The audit log — where a denial is recorded.
- Roles — the screen that shows them.
- Roles in the API and agent endpoints — every route with its role.
- Users — assigning one.
Checked against#
agent/main.go ·
agent/authhttp.go ·
agent/auth.go ·
agent/security.go ·
docs/security.md ·
docs/deploy.md ·
docs/ui.md ·
PLAN.md