Wheelhouse docs

Roles#

There are three roles and they are enforced per route, server-side. A principal — a signed-in person, an API token, or the break-glass token — carries exactly one, and every route in the agent names the minimum role it needs. The web UI greys out what your role cannot do, but that is a courtesy: the check that matters happens in the agent, and a request that gets past a greyed-out button is refused and recorded.

viewer  <  operator  <  admin

The comparison is a rank, so an admin satisfies every check an operator satisfies, and an operator satisfies every check a viewer satisfies (agent/auth.go Role.AtLeast).

What each role may do#

ViewerOperatorAdmin
Read operational state — interfaces, routes, sessions, logs, the audit logyesyesyes
Read the configuration treeyes, redactedyes, redactedyes, in full
Read its own sessionsyesyesall sessions
Change its own password, enrol two-factoryesyesyes
Stage, commit, commit-confirm, discard, roll backnoyesyes
Save the boot configuration, load a configurationnoyesyes
Reconcile against a desired-state filenoyesyes
Install, update, restart or remove an appnoyesyes
Ask for an inline IPS plan; run a traceroutenoyesyes
Write to a fleet membernoyes, with a licence that includes fleetyes, same
Create, change and delete accountsnonoyes
Issue and revoke API tokensnonoyes
Change agent settings, enter a licencenonoyes
Reboot, power off, manage boot imagesnonoyes
Download the unredacted configurationnonoyes

How the route table says it#

The wrappers in agent/main.go routes are the whole authorisation model, and it is readable in one screen:

WrapperMeans
readOnly(h)requireRole(RoleViewer, h) — any authenticated principal.
writeable(h)requireRole(RoleOperator, h).
adminOnly(h)requireRole(RoleAdmin, h).
licensed(h)writeable(requireLicense(h)) — the operator role and a usable licence.

The counts, at the version of the tree this page was checked against: 107 route patterns — 64 readOnly, 4 writeable, 18 licensed, 15 adminOnly, and six that take no authentication at all. GET /metrics is registered either way depending on --metrics-public, and is counted once, in readOnly. Re-derive them at any time:

bash
grep -o 'mux.HandleFunc("[A-Z]* [^"]*", [a-zA-Z]*' agent/main.go | sort

The licence gate is not a role#

Eighteen routes are wrapped in licensed, which is the operator role plus a usable licence. An unlicensed agent answers 402 on those and keeps serving every read, the audit log and the login screen. It is a commercial gate, not a security boundary (agent/license.go requireLicense).

Two write-plane routes are deliberately not licence-gated: POST /api/diagnostics/traceroute and POST /api/ids/ips/plan are operator-only but need no licence, because neither changes configuration — one runs a command, the other renders a plan.

The redaction boundary#

Configuration reads below the admin role are passed through redactSecrets, which blanks the value of any leaf whose name is a credential — private-key, pre-shared-key, password, secret, key and a dozen others — and replaces it with [redacted]. It wraps GET /api/config, /api/config/commands, /api/wireguard, /api/vpn/ipsec, /api/vpn/openvpn, /api/pki, /api/drift, /api/history/diff and the fleet config read (agent/security.go, agent/main.go).

Two boundaries are drawn differently on purpose:

  • GET /api/staged redacts below operator, not admin. The operator who staged a key typed it; showing them [redacted] in their own diff is not a preview. A viewer has no such claim on it.
  • GET /api/config/raw is admin-only rather than redacted. A whole-configuration download with the secrets in it is the backup export, and it is not a read-only action.

What is unauthenticated#

Six route patterns answer without a session or a token, and they are the whole of it — plus /metrics, when the agent was started with --metrics-public:

RouteReturns
GET /health{"status":"ok"}. Deliberately not the version.
GET /api/auth/statusWhether an account exists, whether TLS is on, whether the agent is licensed, whether two-factor is required, and single sign-on's display label and login path.
GET /api/oidc/statusThe same plus the issuer URL and redirect URI — more than the line above withholds.
GET /api/oidc/login, GET /api/oidc/callbackThe sign-on redirect and its return leg.
POST /api/auth/loginThe login itself, behind three rate limiters.
GET /metricsOnly when the agent was started with --metrics-public; otherwise it is readOnly.

What a refusal looks like#

An unauthenticated request gets 401 with a reason. A request from a principal whose role is too low gets 403 with this action requires the <role> role, and an audit entry:

json
{"operation":"denied POST /api/stage","actor":"alice","role":"viewer","success":false,"error":"requires operator"}

That entry is why the greyed-out button is a courtesy. An attempt to use a route your role does not reach is recorded with your name on it (agent/authhttp.go requireRole).

An operator on an unlicensed agent gets 402 with the licence state in the body.

A browser session belonging to an account that has no second factor while require_totp is on gets 403 with totp_enrolment_required and enrol_at on every route except five: /api/auth/totp/begin, /api/auth/totp/confirm, /api/auth/me, /api/auth/password and /api/auth/logout. The gate is evaluated per request against the live policy and the live account, so turning the policy on bites sessions that were already open and turning it off releases them. A single sign-on session is exempt — that second factor belongs to the identity provider — and a token or the break-glass credential is never gated, which is the way back when somebody has locked themselves out of the UI (agent/authhttp.go enrolmentRequired). See Account.

Where a role comes from#

PrincipalRole from
A browser sessionthe account's role, copied into the session when it was issued
An API tokenthe role chosen when the token was issued
The break-glass tokenalways admin
A single sign-on userthe account's role, which group mapping may set — see single sign-on

A session carries the role it was issued with, which is why changing an account's role drops its sessions: the agent will not let a role change be outlived by an open session (agent/admin.go handleUpdateUser).

See also#

Checked against#

agent/main.go, agent/auth.go, agent/authhttp.go, agent/security.go, agent/license.go, agent/admin.go, docs/security.md.

Updated 2026-09-02 roles authorisation api