# Admin API & Users

> The versioned admin REST API of nauthera-server: authorization, admin tokens, users and invitations, their sessions, devices and roles, and organizations.

The admin API is a versioned REST surface under `/v1/admin` on the public listener. It
manages organizations, users, their status and roles, broker memberships and email
domains, and reads clients, identity providers, sessions, the audit log, the threat
signals and the problems operators report. The
[admin dashboard](https://www.nauthera.io/docs/v1/admin-dashboard) is built on it; what the dashboard does not
offer, you do by calling the API directly.

## Authorization

Every call needs an access token, and two checks must both pass:

1. the token carries the operation's **OAuth scope**, and
2. the caller holds the operation's **permission**, which the server maps from the
   caller's roles through a fixed table.

The roles that count are those the caller holds in the organization that issued the
token. For a user's token, the server reads the user's roles from the database on every
call and ignores the token's `roles` claim, so a role removed there stops working on the
next call, and a role added there works without a new token. A `client_credentials`
token's roles are the ones in its `roles` claim, which only an organization's
`{slug}-admin-m2m` client carries. The API contract declares each operation's scope and
permission (`x-permission`).

Only an organization's own reserved clients, `{slug}-admin-console` and
`{slug}-admin-m2m`, can obtain a token that opens the admin API. The gRPC control plane
refuses an admin scope on any other client, and the admin API refuses a token minted for
any other client with `403`.

| Operations | Scope | Permission |
|---|---|---|
| Read who the caller is and what they may do (`/v1/admin/me`) | `nauthera.orgs:read` | `orgs.read` |
| Read an organization, its theme, its email relay and its identity providers | `nauthera.orgs:read` | `orgs.read` |
| List an organization's broker memberships and email-domain claims; list and read the problems operators reported for its resources | `nauthera.orgs:read` | `orgs.read` |
| List and read the problems of resources in no organization | `nauthera.orgs:read` | `orgs.read`, from a platform role |
| List organizations | `nauthera.orgs:read` | `orgs.read`, from a platform role |
| Create, update and delete organizations; send a test email | `nauthera.orgs:write` | `orgs.write` |
| List and read users; list a user's linked identity provider accounts | `nauthera.users:read` | `users.read` |
| Create a user; send a new invitation | `nauthera.users:write` | `users.create` |
| Update a user | `nauthera.users:write` | `users.update` |
| Delete a user | `nauthera.users:write` | `users.delete`, and a recent sign-in |
| Set a user's password; require a new one at their next sign-in | `nauthera.users:write` | `users.credentials`, and a recent sign-in |
| Email a user a password reset link | `nauthera.users:write` | `users.credentials` |
| Suspend, block, reactivate and unlock a user | `nauthera.users:write` | `users.lifecycle`, and a recent sign-in |
| List a user's roles, and the roles the caller may change | `nauthera.roles:read` | `roles.read` |
| Grant or revoke a role | `nauthera.roles:write` | `roles.assign`, and a recent sign-in |
| List clients, read one | `nauthera.clients:read` | `clients.read` |
| List sessions and a user's session history; read a user's known devices | `nauthera.sessions:read` | `sessions.read` |
| End one session | `nauthera.sessions:write` | `sessions.revoke` |
| End every session of a user; forget the browsers a user's second factor remembers | `nauthera.sessions:write` | `sessions.revoke`, and a recent sign-in |
| Read the audit log and its catalog; rank who is behind audit events | `nauthera.audit:read` | `audit.read` |
| Count audit events over time, an organization's users, sessions and clients, a client's users, the deployment's client-address posture | `nauthera.insights:read` | `insights.read` |
| Read the threat signals and the shape of the refused sign-ins | `nauthera.threats:read` | `threats.read` |
| Invite, accept, limit and end broker memberships | `nauthera.brokers:write` | `brokers.manage`, and a recent sign-in |
| Claim, verify and remove email domains | `nauthera.domains:write` | `domains.manage`, and a recent sign-in |

Roles are named `<scope>-<job>`: the scope is the organization the role is held in
(`org`) or every organization (`platform`). They grant these permissions:

| Role | Label | Permissions |
|---|---|---|
| `org-viewer` | Viewer | `orgs.read`, `clients.read`, `insights.read` |
| `org-auditor` | Auditor | `orgs.read`, `users.read`, `users.pii.read`, `clients.read`, `audit.read`, `insights.read`, `threats.read`, `roles.read` |
| `org-support` | Support agent | `orgs.read`, `users.read`, `users.pii.read`, `users.lifecycle`, `users.activity`, `sessions.read`, `sessions.revoke` |
| `org-user-admin` | User administrator | Everything `org-support` holds, plus `users.create`, `users.update`, `users.delete`, `users.credentials`, `roles.read` |
| `org-owner` | Owner | Everything `org-user-admin` and `org-auditor` hold, plus `roles.assign`, `brokers.manage` and `domains.manage` |
| `platform-viewer` | Platform viewer | `orgs.read`, `clients.read`, `insights.read` |
| `platform-auditor` | Platform auditor | `orgs.read`, `clients.read`, `audit.read`, `platform.audit`, `insights.read`, `threats.read`, `roles.read` |
| `platform-owner` | Platform owner | All 21 permissions, `orgs.write` and `platform.audit` included |

The three platform roles count only in the main organization; held in any other
organization, they grant nothing. `users.pii.read` decides whether a caller may search
users by name or email, and whether session listings and known devices include names,
addresses and user agents, and linked accounts their subjects; without it they are
withheld. Audit reads include
platform-scoped records only for a caller with `platform.audit`. A call refused for a
missing permission answers `403`, and its `detail` names the permission. Each refusal by
these checks is recorded as an `admin.access.denied` audit event with
`required_permission` and `required_scope`.

A token acts only in the organization that issued it; on another organization's path it
gets `403`. The exception is a caller who holds a platform role in the main organization:
they can act in any organization, with the permissions their main-org roles grant.
Listing, creating, updating and deleting organizations need such a role, so managing
organizations is reserved for the **main** organization.

### Protected accounts

No administrator changes their own account or a more privileged one. Changing a user's
email or username; setting their password, requiring a new one or emailing them a reset
link; suspending, blocking, reactivating or unlocking them; and deleting them are refused
with `403` `protected_account` when the user is the caller, or holds, in any
organization, a permission beyond `orgs.read`, `clients.read` and `insights.read` that the
caller lacks. Changing any such privileged account also needs a caller holding
`roles.assign`. So a support agent or user administrator acts only on end users and
viewers. Ending a user's sessions only removes access, and is not refused this way.

An [identity broker's](https://www.nauthera.io/docs/v1/identity-broker) linked account takes no credential,
email or username of its own, since its member organization decides all three: setting
its password, emailing it a reset link, or changing its email or username is refused
with `protected_account` from every caller. Each refusal is recorded as
`admin.access.denied` with the reason `protected_account` and its cause.

### Recent sign-in

Deleting a user; setting a password or requiring a new one; suspending, blocking,
reactivating and unlocking a user; granting and revoking a role; ending every session of
a user; forgetting a user's remembered browsers; and every broker-membership and
email-domain write need a sign-in no older than
`auth.admin.max_auth_age` (default `15m`, at most `24h`), with a second factor when the
deployment requires one (`mfa.required`). An older token is answered `401`
`insufficient_user_authentication` with an RFC 9470 challenge carrying `max_age`; sign
in again and repeat the call. A `client_credentials` token has no sign-in, so it never
passes: these operations need an administrator's token from `{slug}-admin-console`, and
no machine client grants a role.

The user, role and session operations among them also take a required `reason` query
parameter (at most 200 characters), as does ending one session. It is recorded on the
audit event and nowhere else.

If the token is DPoP-bound, send it with the DPoP scheme (`Authorization: DPoP <token>`)
and a matching `DPoP` proof on every call. A bound token sent as `Bearer` is refused.

## Getting a token

Each organization has two reserved admin clients:

- **`{slug}-admin-m2m`** for automation, using the `client_credentials` grant. It
  registers the user scopes and `nauthera.audit:read`, plus the organization scopes in
  the main org. Its roles are fixed when it is provisioned: `org-user-admin` and
  `org-auditor`, plus `platform-owner` in the main org. It registers no role, session,
  client, insights or threat scope. For an org created through
  the admin API, its secret is returned once as `admin_m2m_secret`. An org created with
  the gRPC `CreateOrganization` call, which `nauthera-cli create org` uses, gets no
  secret back, so nobody holds it and that client cannot be used. For the main org, the
  secret is written once to `auth.admin.bootstrap_secret_path`, which is **empty by
  default**, and then the secret is discarded.
- **`{slug}-admin-console`**, a public PKCE client for people, which the
  [admin dashboard](https://www.nauthera.io/docs/v1/admin-dashboard) signs in with. It registers every admin
  scope the dashboard uses: users, audit, clients, sessions, insights, threats and
  roles, `nauthera.brokers:write` and `nauthera.domains:write`, and `nauthera.orgs:read`
  (both organization scopes in the main org). The admin API reads the signed-in user's
  roles from the database on every call, so the client's
  tokens open what the user's roles in that organization grant, and nothing for a user
  who holds none. Its redirect URI is `{issuer}/admin/callback`.

```bash
TOKEN=$(curl -s https://auth.example.com/token \
  -u "default-admin-m2m:$ADMIN_M2M_SECRET" \
  -d grant_type=client_credentials \
  -d scope="nauthera.users:read nauthera.users:write" | jq -r .access_token)
```

### The first administrator

On a fresh install no user account holds an admin role. There are two ways in:

- **The bootstrap secret.** Set `auth.admin.bootstrap_secret_path` to a writable path
  before the server's **first** start, read the `default-admin-m2m` secret from that
  file, and use the `client_credentials` request above. The Helm chart leaves this off
  (`adminBootstrap.enabled: false`) because its image has no shell to read the file
  with; you need a sidecar or debug container that shares the volume.
- **A promoted user.** Create the user's account, then list their email address in
  `auth.admin.subjects` and restart. The account must exist first: self-registration
  refuses an address that is listed there, and in the main org the admin API refuses to
  create an account with a listed address, or to change an account's email to one,
  unless the caller already holds everything the bootstrap grants. The user is granted
  `platform-owner` in the main org, which holds every permission in every organization.
  They then sign in to the [admin dashboard](https://www.nauthera.io/docs/v1/admin-dashboard) at
  `{issuer}/admin/`, or use `default-admin-console` with the authorization code flow and
  PKCE from their own tooling (see
  [Connect an Application](https://www.nauthera.io/docs/v1/integration#4-send-the-user-to-sign-in)).

Either way, the grant happens once, and is bound to the account rather than the address.
Removing an address from `auth.admin.subjects` later demotes nobody; revoke the role
through the [roles operations](#roles) instead, and a restart does not undo the revoke.

From there, a platform owner grants roles to other users. To hand a tenant organization
to its own administrators, create a user in it and grant them `org-owner` there; they
then grant roles in their organization from its own dashboard.

## Users

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/admin/orgs/{slug}/users` | List and search users (paginated) |
| `POST` | `/v1/admin/orgs/{slug}/users` | Create a user |
| `GET` | `/v1/admin/orgs/{slug}/users/{id}` | Read a user |
| `PATCH` | `/v1/admin/orgs/{slug}/users/{id}` | Update email, username, `email_verified` or attributes |
| `DELETE` | `/v1/admin/orgs/{slug}/users/{id}?reason=…` | Delete a user and revoke their sessions and refresh tokens |
| `PUT` | `/v1/admin/orgs/{slug}/users/{id}/password?reason=…` | Set a new password and end the user's sessions |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/password-change?reason=…` | Send the user's next password sign-in to the page that sets a new password |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/password-reset` | Email the user the forgot-password link, naming the administrator |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/suspend?reason=…` | Suspend the user, until `until` or until reactivated |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/block?reason=…` | Block the user until reactivated |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/reactivate?reason=…` | End a suspension or block |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/unlock?reason=…` | Release the lock repeated wrong passwords set |
| `POST` | `/v1/admin/orgs/{slug}/users/{id}/invitation` | Email a user who has no password yet a new invitation |
| `GET` | `/v1/admin/orgs/{slug}/users/{id}/devices` | The user's known browsers and networks |
| `DELETE` | `/v1/admin/orgs/{slug}/users/{id}/devices/remembered?reason=…` | Stop remembering one browser (`device`), or all of them, for the user's second factor |
| `GET` | `/v1/admin/orgs/{slug}/users/{id}/identities` | The identity provider accounts linked to the user |
| `GET` | `/v1/admin/orgs/{slug}/users/{id}/roles` | The user's roles, and the roles the caller may change |
| `PUT` | `/v1/admin/orgs/{slug}/users/{id}/roles/{role}?reason=…` | Grant a role |
| `DELETE` | `/v1/admin/orgs/{slug}/users/{id}/roles/{role}?reason=…` | Revoke a role |

```bash
curl -s https://auth.example.com/v1/admin/orgs/default/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "alice@example.com",
        "password": "a-long-initial-password",
        "attributes": { "name": "Alice Example" }
      }'
```

- `email` is required, and exactly one of `password` and `invite: true`; both or neither
  is refused `400`. The password must have between `auth.password.min_length` and
  `auth.password.max_length` characters (8 and 128 by default). It is hashed with
  argon2id, or with PBKDF2-HMAC-SHA-256 in an organization in FIPS mode. For `invite`,
  see [Inviting a user](#inviting-a-user).
- `username` defaults to the lowercased email.
- `attributes` is a free-form map of strings. On `PATCH`, it **replaces** the stored map.
- `email_verified: true` on create or update marks the address as verified, for an
  address the administrator vouches for. Changing `email` leaves the new address
  unverified unless `email_verified: true` is sent with it.
- Emails are unique **per organization**.
- A user record includes `status`, `email_verified_at`, `password_set`,
  `password_change_required`, `invitation_pending`, `second_factors` (the methods of the
  user's active second factors), `failed_login_attempts`, `locked_until` and
  `last_login_at`. The password hash is never returned.
- Changing a user's email, username, password or status and deleting them follow the
  [protected-account rule](#protected-accounts). In the main org, an address listed
  in `auth.admin.subjects` is refused like a taken one unless the caller holds
  everything the bootstrap grants.
- Admin access is not a property of the user: it comes only from roles.

### Inviting a user

With `"invite": true` instead of a password, the user is created without one, and their
address is emailed a single-use link to the hosted page `/accept-invitation`, naming the
administrator who invited them. Following it sets their password and verifies the
address. `locale` picks the language of the message (English, German or French; any
other falls back to English). The organization needs an
[email relay](https://www.nauthera.io/docs/v1/email): without one, the create is refused `409`
`mail_relay_missing` and nothing is stored.

```bash
curl -s https://auth.example.com/v1/admin/orgs/default/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "email": "bob@example.com", "invite": true, "locale": "de" }'
```

A link works for `auth.invitation.validity` (default `168h`, at most 30 days), and stops
working once the account has a password, however it got one, or holds another address.
Until then, signing in to the account is refused as a wrong password is. That sign-in, a
password-reset or verification request for the address, or a sign-up with it, is answered
as for any address and emails the account a new invitation, naming the organization as
the inviter, whose link returns to the application the user started from
([Emailed links](https://www.nauthera.io/docs/v1/sign-in-flows/emailed-links#coming-back-without-the-link)).
`POST …/users/{id}/invitation` sends a new link; earlier ones keep working until they
expire or one is accepted. It is refused `409` `conflict` when the user already has a
password, and `429` once the user was sent `auth.invitation.max_requests` (default `5`)
links within one validity window, the ones they asked for themselves included. `invitation_pending` is true while the user has no
password and no linked identity provider account, so an account a provider created is
never shown as waiting on an invitation. Sending one is recorded as
`user.invitation.sent`, and accepting it as `auth.invitation.accepted`.

### Finding users

`GET /v1/admin/orgs/{slug}/users` takes a search and filters, which combine:

| Parameter | Matches |
|---|---|
| `q` | Users whose username, display name or email starts with it, case-insensitively (2 to 100 characters) |
| `match` | Which of `username`, `display_name` and `email` `q` searches. Name and email need `users.pii.read`; asking for one without it is refused with `403`. |
| `status` | Comma-separated statuses in force now: `active`, `suspended`, `blocked` |
| `locked` | `true` for users the lock after repeated wrong passwords refuses now, `false` for the rest |
| `second_factor` | `true` for users with an active second factor, `false` for the rest |
| `email_verified` | `true` for users whose address is verified, `false` for the rest |
| `created_from`, `created_to` | Creation time, start included, end excluded |
| `last_sign_in_from`, `last_sign_in_to` | Last sign-in time. Users who never signed in match `last_sign_in_to` and not `last_sign_in_from`. |

```bash
curl -s "https://auth.example.com/v1/admin/orgs/default/users?q=ali&status=active&sort=-last_login_at" \
  -H "Authorization: Bearer $TOKEN"
```

### Account status

An administrator's decision about an account is its `status`, separate from the automatic
lock after repeated wrong passwords:

- **`active`** may sign in.
- **`suspended`** may not, until it is reactivated or its `suspended_until` passes, when
  it reads `active` again by itself.
- **`blocked`** may not, until it is reactivated.

A suspended or blocked account cannot sign in, and cannot obtain or renew a token through
any grant. The user record carries `status_reason`, `suspended_until` and
`status_changed_at`.

`POST …/users/{id}/suspend` (with an optional `until`), `/block` and `/reactivate` change
the status, and `/unlock` releases the automatic lock before it lifts by itself. Each needs
`users.lifecycle`, a [recent sign-in](#recent-sign-in) and a `reason`. Suspending and
blocking end every session the user holds, retire their refresh grants and tell relying
parties over back-channel logout; after reactivation the user signs in again. A
suspension, block or reactivation emails the account's holder, and the changes are
recorded as `user.suspended`, `user.blocked`, `user.reactivated` and `user.unlocked`.

By default a refused sign-in answers like a wrong password.
`auth.brute_force.reveal_lockout` tells a caller with the right password that the account
is suspended or blocked, and tells any caller that a lock stands.

### Known devices

`GET /v1/admin/orgs/{slug}/users/{id}/devices` lists the browsers and networks the user
signed in from, most recently seen first, with when each was first and last seen. These
are what a [new-sign-in alert](https://www.nauthera.io/docs/v1/sessions#new-sign-in-alerts) compares a sign-in
with. A browser is named by its browser and system (`Firefox on Linux`), and a network by
its range (`203.0.113.0/24`). Without `users.pii.read` the names are withheld and
`withheld` is `true`. Reading the list is audited.

A browser can also be remembered for the user's second factor, where the organization's
[policy](https://www.nauthera.io/docs/v1/mfa#sparing-the-challenge) spares the challenge on one.
`DELETE /v1/admin/orgs/{slug}/users/{id}/devices/remembered` forgets the browser the
`device` parameter names (its `id` in the list), or every browser of the user without it,
so the next sign-in from it asks for the factor. The browsers stay known, so a sign-in
from one is not reported as new. It needs a recent sign-in and a `reason`, and is
recorded as `user.remembered_browsers.forgotten`.

### Linked accounts

`GET /v1/admin/orgs/{slug}/users/{id}/identities` lists the accounts at the
organization's identity providers that sign in as the user, oldest link first: the
provider, the issuer that assigned the subject, the subject, and when the account was
first linked. `active` is false once the provider was deleted or re-pointed at another
issuer, so the link no longer signs anyone in. Without `users.pii.read` the subjects are
withheld and `withheld` is `true`. Reading the list is audited as
`user.identities.listed`. `GET /v1/admin/orgs/{slug}/identity-providers` lists the
providers themselves, without their secrets; they are declared with an
[`IdentityProvider`](https://www.nauthera.io/docs/v1/identity-provider-resource) resource.

### Sessions

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/admin/orgs/{slug}/sessions` | Active sessions, filtered by `user_id` or `client_id` (paginated) |
| `GET` | `/v1/admin/orgs/{slug}/users/{id}/session-history` | A user's ended sessions and why they ended, between `from` and `to` (default the last 30 days, at most 90) |
| `DELETE` | `/v1/admin/orgs/{slug}/sessions/{sid}?reason=…` | End one session |
| `DELETE` | `/v1/admin/orgs/{slug}/users/{id}/sessions?reason=…` | Sign the user out everywhere |

Each session carries its public `sid`, the one its applications receive, never the
browser's credential. Ended sessions are kept for `auth.session.history_retention`. See
[Sessions, Consent & Logout](https://www.nauthera.io/docs/v1/sessions#what-ends-a-session).

### Signing up, verifying an address, resetting a password

Users can register themselves on the hosted UI when `auth.registration.enabled` is on.

The sign-in app also offers two links by email. Both need the organization to have an
[email relay](https://www.nauthera.io/docs/v1/organizations#email), and both answer `202` whether or not the
address has an account, so neither tells who is registered:

- **Password reset.** `POST /v1/auth/password-reset` emails a single-use link to set a
  new password; the sign-in app's forgot-password page sends it. Setting the new
  password spends every reset link the account holds, ends its sessions and the grants
  issued under them, and clears a lockout, as an administrator's reset does. A link
  works for `auth.password_reset.validity` (default `30m`, at most `24h`), and an
  account is sent at most `auth.password_reset.max_requests` (default `3`) in that time.
- **Email verification.** `POST /v1/auth/verify-email/resend` emails a single-use link
  that marks the address as verified, which ID tokens and UserInfo publish as
  `email_verified`. It is sent only for an address not yet verified. A link works for
  `auth.email_verification.validity` (default `24h`, at most 7 days), with at most
  `auth.email_verification.max_requests` (default `3`) per account in that time. Nothing
  sends one automatically at registration.

A link that is unknown, expired or already used is refused with `invalid_reset_link` or
`invalid_verification_link`. Requests count toward the per-address sign-in throttle.

### Ending a user's access

Setting a password is meant as an incident-response control. It ends **every** session
the user has, retires every refresh token the user was issued, drops any sign-in that is
halfway through an MFA step, and tells the applications of each ended session over
back-channel logout. The user keeps the account and signs in again with the new
password. Deleting a user revokes all of their refresh tokens and ends their sessions
before the account is removed. Ending one session or all of a user's sessions, above,
does the same for sessions alone.

Access tokens already issued stay valid until they expire, because revocation does not
reach access tokens yet (#91). Keep `issuer.access_token_ttl` short.

## Clients

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/admin/orgs/{slug}/clients` | List the organization's OIDC clients (paginated, searchable with `q`) |
| `GET` | `/v1/admin/orgs/{slug}/clients/{id}` | One client's registered configuration |
| `GET` | `/v1/admin/orgs/{slug}/clients/{id}/usage` | Its daily, monthly and all-time users |

These are read-only. Clients are registered and changed through the gRPC
`AdminService`, the operator or dynamic registration; see
[Clients](https://www.nauthera.io/docs/v1/clients).

## Organizations

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/admin/orgs` | List organizations |
| `POST` | `/v1/admin/orgs` | Create an organization: `slug`, and optionally `display_name`, `fips_mode`, `fapi_mode`, `email_verification_required`, `branding`, `second_factor` and `network_zones` |
| `GET` | `/v1/admin/orgs/{slug}` | Read one |
| `PATCH` | `/v1/admin/orgs/{slug}` | Change any of those but `slug` and `fips_mode`, which are fixed at creation |
| `DELETE` | `/v1/admin/orgs/{slug}` | Delete it (not the main org) |
| `GET` | `/v1/admin/orgs/{slug}/theme` | The theme the admin dashboard wears for it |
| `GET` | `/v1/admin/orgs/{slug}/mail` | Its email relay, without credentials |
| `POST` | `/v1/admin/orgs/{slug}/mail/test` | Send a test email to the caller's own address |
| `GET` | `/v1/admin/orgs/{slug}/identity-providers` | Its identity providers, without their secrets |
| `GET` | `/v1/admin/orgs/{slug}/snapshot` | Count its users, sessions and clients |
| `GET`, `POST` | `/v1/admin/orgs/{slug}/broker-memberships` | List its [broker memberships](https://www.nauthera.io/docs/v1/identity-broker#the-membership), on either side; invite a member |
| `POST` | `/v1/admin/orgs/{slug}/broker-memberships/{id}/accept` | Accept a broker's invitation, as the member |
| `PUT` | `/v1/admin/orgs/{slug}/broker-memberships/{id}/clients` | Choose which of the broker's clients the membership admits |
| `DELETE` | `/v1/admin/orgs/{slug}/broker-memberships/{id}` | End a membership, from either side |
| `GET`, `POST` | `/v1/admin/orgs/{slug}/email-domains` | List its [email-domain claims](https://www.nauthera.io/docs/v1/identity-broker#routing-by-email-domain); claim a domain |
| `POST` | `/v1/admin/orgs/{slug}/email-domains/{id}/verify` | Verify a claim against its DNS TXT record |
| `DELETE` | `/v1/admin/orgs/{slug}/email-domains/{id}` | Remove a claim |
| `GET` | `/v1/admin/orgs/{slug}/problems`, `/v1/admin/orgs/{slug}/problems/{id}` | The resources an operator could not apply for it, with the reason and its runbook |

Beside them, `GET /v1/admin/problems` lists, for platform staff, the problems of
resources in no organization, `GET /v1/admin/me` says who the caller is and what they may
do, and `GET /v1/admin/deployment/client-ip` reports how the deployment reads the client
address. The [API reference](https://www.nauthera.io/docs/v1/api) holds every operation. See
[Organizations](https://www.nauthera.io/docs/v1/organizations).

## Pagination and sorting

Lists use opaque **cursor (keyset) pagination**:

```bash
curl -s "https://auth.example.com/v1/admin/orgs/default/users?limit=50&sort=-created_at,email" \
  -H "Authorization: Bearer $TOKEN"
```

- `sort` takes a comma-separated list of fields. A leading `-` sorts that field
  descending. The primary key is appended so the order is total.
- The response has `items` and a `page` object. Follow `page.next_cursor` while
  `page.has_more` is true, and use `page.prev_cursor` to go back. No total count is
  returned. A cursor is bound to the search and filters it was issued for, so send them
  with it.
- Sortable fields are declared per endpoint and checked against the request. Users can
  be sorted by `created_at`, `email`, `id`, `last_login_at` and `username`; users who
  never signed in sort first ascending and last descending. Organizations can be sorted
  by `created_at`, `display_name`, `id` and `slug`, clients by `client_id`,
  `created_at`, `display_name` and `id`, and active sessions by `created_at` and `id`.

## Roles

Roles are stored per user and organization; see [Authorization](#authorization) for what
each grants and where the server reads them. Access tokens also carry a user's roles in
the `roles` claim, set when the token is issued, but the admin API ignores the claim on a
user's token. Deleting a user removes their roles.

`GET /v1/admin/orgs/{slug}/users/{id}/roles` returns the roles the user holds in the
organization, and `assignable`: the roles the caller may grant to or revoke from them.

```bash
curl -s -X PUT \
  "https://auth.example.com/v1/admin/orgs/acme/users/$USER_ID/roles/org-owner?reason=New+team+lead" \
  -H "Authorization: DPoP $TOKEN" -H "DPoP: $PROOF"
```

A grant or a revoke answers `204`, also when the user already held the role or did not
hold it. Both need `roles.assign`, which only `org-owner` and `platform-owner` hold, a
recent sign-in and a `reason`, within these bounds:

- **Within your own authority.** A caller grants or revokes a role only when their own
  roles cover every permission it grants, and its platform reach. So an `org-owner`
  hands out every organization role, `org-owner` included, and no platform role.
- **Never your own.** Nobody grants or revokes their own roles.
- **Platform roles in the main org only.** Granted anywhere else, they would grant
  nothing, so they are refused.
- **Never the last one who can grant.** Revoking a role from the organization's last
  active account holding `roles.assign` is refused `409` `last_role_administrator`, so
  no organization is left without someone who can grant roles.

The first three answer `403` `protected_account`. The role must be one of the eight
built-in ids. The user is emailed when a role they did not hold is granted, or one they
held is revoked, through the organization's relay when it has one (see
[Email](https://www.nauthera.io/docs/v1/email)). Each change is recorded as `role.granted` or `role.revoked`
with the role, the reason and whether the user was `notified`; a refusal is recorded too.
A role counts from the user's next request.

Grants are made within one organization, to that organization's own users. Granting
main-org staff a role in another organization (#543), listing everyone who holds a role
in an organization (#601, #332), further bounds on owner-tier grants (#542), monitor and
helpdesk-admin roles (#635) and per-application access for developers (#623) are
planned. See [Admin Dashboard](https://www.nauthera.io/docs/v1/admin-dashboard#still-planned).
