Wheelhouse docs

Role matrix#

There are three roles and they are strictly ordered: viewer < operator < admin. A principal may call a route if its role is at least the route's minimum. That check happens in one place — requireRole in agent/authhttp.go — and the minimum is written on the route registration, not inside the handler, so the whole authorisation model is readable by reading routes().

The three roles#

RoleRankMay
viewer1Read every operational and configuration endpoint — with secret leaves blanked — and manage its own session, password and second factor.
operator2Everything a viewer may, plus stage, commit, commit-confirm, discard, configure, roll back, save and load configuration, reconcile, app lifecycle, traceroute, the IPS plan, and reading the desired-state file.
admin3Everything an operator may, plus accounts, API tokens, agent settings, the licence, power and boot images, the unredacted configuration export, and the single sign-on view.

An unknown role string is invalid: Role.Valid() refuses anything that is not one of the three, and every endpoint that accepts a role (POST /api/admin/users, PATCH /api/admin/users/{name}, POST /api/admin/tokens, --oidc-default-role) rejects it.

The four wrappers#

Every route in routes() is wrapped in exactly one of these, or in none:

WrapperMeans
(none)Unauthenticated. Five routes.
readOnly(h)requireRole(RoleViewer, h)
writeable(h)requireRole(RoleOperator, h)
adminOnly(h)requireRole(RoleAdmin, h)

Two more wrappers compose with those and gate on the licence rather than on the role:

WrapperMeans
requireLicense(h)402 unless the licence is usable. The route table defines licensed = writeable(requireLicense(h)), which is what almost every write uses.
requireFeature("fleet", h)requireLicense and the licence's plan must carry that feature; otherwise 402 with "license":"feature".

And one that gates the response rather than the request:

WrapperMeans
redactSecrets(need, h)Below role need, secret leaves in the response are replaced with [redacted]. See What a read hides.

What each role may call#

Viewer#

Everything in the read plane. Concretely: /api/version, /api/system, /api/config, /api/config/commands, /api/interfaces and its three siblings, /api/neighbors, /api/routes, /api/wan, /api/vrrp, /api/lldp, /api/vpn/ipsec, /api/vpn/openvpn, /api/wireguard, /api/diagnostics/lookup, /api/dhcp/leases, /api/dhcp/reservations, /api/dns, /api/firewall/rules, /api/firewall/stats, /api/firewall/groups, /api/nat/rules, /api/nat/stats, /api/qos, /api/qos/stats, /api/sessions, /api/conntrack/stats, /api/routing, /api/ids, /api/drift, /api/daemons, /api/apps, /api/apps/catalog, /api/apps/installed, /api/apps/hints/{page}, /api/apps/logs/{name}, /api/containers, /api/services, /api/processes, /api/pki, /api/ntp, /api/ddns, /api/log, /api/audit, /api/history, /api/history/diff, /api/staged, /api/stream, /api/license, /api/fleet, and /metrics unless --metrics-public is set.

Plus its own session: /api/auth/logout, /api/auth/me, /api/auth/password, the three /api/auth/totp/* routes, /api/auth/sessions, DELETE /api/auth/sessions/{id} and /api/auth/oidc/unlink.

Plus, with a licence carrying the fleet feature, /api/fleet/{id}/config and /api/fleet/{id}/version.

Operator, without a licence#

Three routes are operator-gated and licence-free, because none of them changes the configuration:

MethodPathWhy it is not licensed
POST/api/diagnostics/tracerouteA command on the router, not a configuration change.
POST/api/ids/ips/planRenders operations; stages nothing.
GET/api/reconcile/fileReads a file out of the data directory and renders it as operations — not a viewer's business, but not a write either.

Operator, with a licence#

POST /api/stage, /api/stage/remove, /api/commit, /api/commit/confirm, /api/discard, /api/configure, /api/rollback, /api/config/save, /api/config/load, /api/reconcile, /api/capture, /api/apps/plan, /api/apps/pull, /api/apps/prepare, /api/apps/update, /api/apps/restart, /api/apps/feature, and DELETE /api/apps/image.

Plus, with the fleet feature, POST /api/fleet/{id}/configure.

Admin#

GET /api/config/raw (the unredacted backup export), /api/admin/oidc, the four /api/admin/users routes, the three /api/admin/tokens routes, GET/PUT /api/admin/settings, POST /api/system/power, POST /api/system/image, and PUT/DELETE /api/admin/license.

None of the admin plane needs a licence. An unlicensed router must still be able to manage its own accounts — and to enter the licence that unlocks the rest.

What happens when the role is not enough#

json
{"error": "this action requires the operator role"}

Status 403, and the refusal is recorded in the audit log as an entry whose operation is denied POST /api/commit, with the actor, the role it had, the source address and requires operator as the error. A denial is a fact worth keeping.

Role changes take effect immediately#

Changing an account's role, password or identity-provider link through PATCH /api/admin/users/{name} drops every session that account holds, so a demotion cannot be outlived by an open browser tab. Changing your own password does the same to your other sessions and keeps the one you are using.

Two refusals exist to stop a router from becoming unmanageable, and both are 409:

  • demoting or deleting the last admin;
  • deleting the account you are signed in as.

The count and the change happen under one write lock, so two concurrent demotions of the last two admins cannot both see "2" and leave none.

Special principals#

PrincipalName in the audit logRole
A signed-in accountthe account namethe account's
An API tokentoken:<label>the token's
The break-glass tokenadmin-tokenalways admin
The reconcile loopreconcile-loop, role systemnot a request principal
First-run bootstrapsystemnot a request principal

admin-token and any name beginning token: are reserved: the agent refuses to create an account with one, so the audit trail is never ambiguous about which of these acted.

Enrolment sessions are narrower than viewer#

When the require_totp policy is on and an account has not enrolled, its session is marked enrolling and may reach only four paths — /api/auth/totp/begin, /api/auth/totp/confirm, /api/auth/me and /api/auth/logout. Everything else answers 403 with "totp_enrolment_required": true, whatever the account's role is.

See also#

Checked against#

agent/main.go (routes), agent/authhttp.go (requireRole, readOnly, writeable, adminOnly, enrolmentPaths), agent/auth.go (Role, roleRank, AtLeast, Valid), agent/license.go (requireLicense, requireFeature), agent/security.go (redactSecrets), agent/admin.go (handleUpdateUser, handleDeleteUser, validUserName), agent/desired.go (recordLoopAudit), docs/security.md "Roles".

Updated 2026-09-02 api roles authorisation