Skip to main content
Fleet Manager authorizes every API request through DefaultPermissions in src/fleet_manager/api/auth/permissions.py. The class supports two authenticated principals and runs only the checks that apply to the caller:
  • Token-only (agents, CLI, CI via client-credentials) — request.auth is set, no user. Only OAuth scope check runs.
  • Session-only (browsable API, admin UI) — user is authenticated, no token. Only Django model permission check runs.
  • Token + user (OAuth auth-code flow with a delegated user identity) — both checks run and both must pass.
  • Neither — request is rejected with 401.
A request whose token carries insufficient scopes, or whose user lacks the Django model permission, is rejected with 403. Missing principal altogether is 401. For how clients obtain tokens in the first place, see Client Authentication.

Scope shape

A scope is a <resource>:<action> string. Resources use kebab-case; actions are one of read, create, update, delete, or a domain-specific verb (job:cancel).
The canonical, exhaustive list lives in _ALL_SCOPES in src/fleet_manager/api/auth/scopes.py. Every scope string used anywhere — in ensureagentclient, in tests, in client assertions — must appear there.

Scope aliases

Two aliases expand at token-issue time (expand_scope_aliases in scopes.py) so a caller can avoid naming individual scopes:
  • all-permitted — every scope the principal is allowed (their Application.allowed_scopes intersected with their Django permissions). This is the default scope for fleetctl api login.
  • read-only — just the read subset (*:read) of what the principal is allowed. Use it to cap a token to reads even when the user also holds write scopes, e.g. an agent logging in on a human’s behalf: fleetctl api login --scope read-only.
read-only takes precedence: if both aliases are requested the token is still capped to reads.

How the required scope for an endpoint is derived

Endpoints do not declare their scope explicitly. DefaultPermissions.get_scopes() derives it from the viewset’s queryset and the HTTP action: The resource is the viewset’s model name, camel-split and kebab-joined (e.g. DeviceGroup → device-group). A PATCH on DeviceGroupViewSet therefore requires device-group:update. Custom viewset actions (e.g. @action(detail=True, methods=["post"]) def cancel) that don’t fit the CRUD mapping use the action name as the verb — so cancel produces job:cancel. Both strings must still be present in _ALL_SCOPES.

Attaching scopes to a client

When you register an agent (see Client Authentication → Registering a client), its roster entry lists the scopes granted to every token issued to that client. For example, the netbox agent’s entry in deployment/docker/bootstrap-clients.yaml (abridged — it also carries the location, rack, device-type, and device-role scopes):
For ad-hoc local experimentation, the same shape works through the Makefile target:
The list is validated against _ALL_SCOPES and stored on Application.allowed_scopes. At token issue time, the access token carries exactly those scopes.

The * provisioning sentinel

A roster entry may set scopes: ["*"] instead of naming each one. It expands at provisioning time to every scope in _ALL_SCOPES, so the entry does not need editing each time a scope is added.
It is accepted only for kind: browser clients, and both the manifest and ensureagentclient reject it anywhere else. The reason is that * widens what the application may hold, not what any token actually gets: a browser login intersects the application’s scopes with the signed-in user’s Django permissions, so a human still only receives what their own account permits. A machine client has no such second cap, so * there would hand it the entire API — which is what a roster of named scopes exists to prevent. It also cannot be combined with named scopes; ["*", "site:read"] is rejected rather than silently widened. Do not confuse this with the all-permitted alias above. all-permitted is sent by a client per token request and means “everything I am permitted right now”; * is written in a roster and describes what the application may ever hold.

What happens on mismatch

  • 401 Unauthorized at /o/token/ — the token was never issued. Most common causes: no Application record for the submitted client_id (missing from the client roster), cert CN doesn’t match the submitted client_id, or the assertion JWT’s signature / iss / sub don’t verify. For secret-based clients, invalid_client usually means the secret is wrong or belongs to another environment’s registry — not a scope or grant-type problem.
  • 401 Unauthorized at an API endpoint — no token and no authenticated user on the request.
  • 403 Forbidden at an API endpoint — token is valid but its scopes do not cover the required scope, or the authenticated user lacks the Django model permission.
The fastest way to diagnose a 403 on a new endpoint is to check (a) whether the endpoint’s resource+action appears in _ALL_SCOPES, and (b) whether the calling client’s roster entry lists it. For 401s, check fm-api logs — authenticate_client logs the specific reason (application not found, CN mismatch, signature failure, …).

Adding a new scope

Adding a new model with API endpoints means adding the corresponding scopes:
  1. Add <resource>:read, <resource>:create, <resource>:update, (and delete if the viewset supports it) to _ALL_SCOPES.
  2. Add the paired Django-permission mapping in _SCOPE_PERMISSIONS in the same file, so session users with the equivalent model permission continue to work.
  3. Update both client rosters (api.migration.clients / satelliteClients in deployment/charts/hub/values.yaml, and deployment/docker/bootstrap-clients.yaml) for agents that need access.
Missing step 1 surfaces as 403 for token clients immediately after deploy, even though the viewset is wired correctly.

Where the pieces live

  • src/fleet_manager/api/auth/scopes.py — scope registry (_ALL_SCOPES) and permission mapping (_SCOPE_PERMISSIONS).
  • src/fleet_manager/api/auth/permissions.py — DefaultPermissions class; derives required scopes, enforces both token and user checks.
  • src/fleet_manager/api/auth/management/commands/ensureagentclient.py — attaches scopes to a client.
  • src/fleet_manager/api/settings.py — wires DefaultPermissions as the DRF default permission class.