> ## Documentation Index
> Fetch the complete documentation index at: https://docs.internal.nscale.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scopes & Permissions

> How Fleet Manager authorizes API requests via OAuth scopes and Django permissions.

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](./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`).

```
device:read           site:create           job:cancel
device-group:update   device-type:read      tenant:update
```

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:

| HTTP method / DRF action | Action verb |
| - | - |
| `GET`, `HEAD`, `OPTIONS`, `list`, `retrieve`, `metadata` | `read` |
| `POST`, `create` | `create` |
| `PUT`, `PATCH`, `update`, `partial_update` | `update` |
| `DELETE`, `destroy` | `delete` |

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](./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):

```yaml theme={null}
- name: netbox-agent
  scopes:
    - "organisation:read"
    - "organisation:create"
    - "site:read"
    - "site:create"
    - "device:read"
    - "device:create"
    - "device:update"
```

For ad-hoc local experimentation, the same shape works through the Makefile target:

```bash theme={null}
make register-agent NAME=netbox-agent SCOPES="organisation:read,site:read,device:read"
```

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.

```yaml theme={null}
- name: fleetctl-api
  kind: browser
  redirect_uris:
    - http://127.0.0.1:8765/callback
  scopes:
    - "*"
```

**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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.