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.authis 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.
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).
_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 (theirApplication.allowed_scopesintersected with their Django permissions). This is the default scope forfleetctl 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 indeployment/docker/bootstrap-clients.yaml (abridged — it also carries the location, rack, device-type, and device-role scopes):
_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.
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: noApplicationrecord for the submittedclient_id(missing from the client roster), cert CN doesn’t match the submittedclient_id, or the assertion JWT’s signature /iss/subdon’t verify. For secret-based clients,invalid_clientusually 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.
_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:- Add
<resource>:read,<resource>:create,<resource>:update, (anddeleteif the viewset supports it) to_ALL_SCOPES. - Add the paired Django-permission mapping in
_SCOPE_PERMISSIONSin the same file, so session users with the equivalent model permission continue to work. - Update both client rosters (
api.migration.clients/satelliteClientsindeployment/charts/hub/values.yaml, anddeployment/docker/bootstrap-clients.yaml) for agents that need access.
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—DefaultPermissionsclass; 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— wiresDefaultPermissionsas the DRF default permission class.