> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Rolter Control Plane: Roles, Scopes, and Permissions

> Assign admin, member, and viewer roles at org, team, or project scope to control what each user can manage in the Rolter control plane.

Rolter's role-based access control (RBAC) system governs what each human user can do in the control plane — creating routes, managing virtual keys, adjusting budgets, and reading dashboards. Roles are lightweight and composable: you assign them per scope, so a user can hold different permissions in different parts of your organisation.

## Roles

Rolter ships with three built-in roles. Every role applies within the scope it is granted — it does not automatically extend to parent or sibling scopes.

<CardGroup cols={3}>
  <Card title="admin" icon="shield-check">
    Full control within the assigned scope. Admins can manage providers, routes, virtual keys, members, and budgets. Use this role sparingly — at most for a small platform team.
  </Card>

  <Card title="member" icon="pencil">
    Mint and rotate their own virtual keys, connect their own MCP sessions, and read the request logs of their scope with the captured request and response bodies. Routes, shared keys, providers and budgets stay an admin's, and members cannot manage other users' memberships.
  </Card>

  <Card title="viewer" icon="eye">
    Read-only access to dashboards, request logs, and usage data — without the captured request and response bodies, unless a project admin allows it for that project. Assign this role to stakeholders who need visibility without the ability to make changes.
  </Card>
</CardGroup>

## Deployment roles

Some settings belong to the whole deployment rather than to an organisation: cluster nodes and draining, the runtime policy, feature flags, alerting, connectors, the security and logging settings, guardrails, the configuration export and the deployment-wide audit log. There is no organisation to hold a role in, so none of the roles above reaches them, and until now the only way in was a superadmin account, which also reads every tenant. Two narrower **deployment roles** exist for the two jobs that need only part of it. A superadmin gives one to an account; an account holds at most one.

| Role               | For                      | Reaches                                                                                                                                                                                              |
| ------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `operator`         | maintaining the platform | cluster nodes (read, drain, remove), the runtime policy, feature flags, alert channels, rules and history (those of every organization as well as the deployment's), and connectors: read and change |
| `security_auditor` | reviewing it             | read only: the security, logging and compatibility settings, guardrail rules, providers and policies, the deployment-wide audit log and the accounts it names, and the configuration export          |

Neither role reads tenant request logs or captured payloads, mints or reads a credential, or grants any role: a deployment role gives nothing inside an organisation, team or project, and granting one is a superadmin's alone. The anchors for what each role can and cannot do are `GET /api/v1/rbac/matrix`, which lists the roles that reach each row as `deployment_roles`, and `GET /api/v1/rbac/effective`, which returns yours as `deployment_role`.

An operator who can change **Connectors** can point telemetry export at an endpoint of their choosing, so treat the role as trusted with the telemetry that connectors carry.

In the dashboard, **Governance › Users** shows each account's deployment role beside its name, and a superadmin sets or clears it with the **Set the deployment role** button on the account's row. Through the API, give or clear a role with `PUT /api/v1/users/{id}/deployment-role`, as a superadmin or with the admin token:

```bash
curl -s -X PUT https://control.example.com/api/v1/users/$USER_ID/deployment-role \
  -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "operator"}'
```

`role` is `operator`, `security_auditor` or `null` to clear it; anything else is `400`. The change applies on the account's next request, and every grant is an audit row, `user.deployment_role`, in the deployment-wide audit log with the previous and the new role. The dashboard shows each screen to the roles that reach it and a forbidden page to everyone else.

## Scopes

Roles are always granted at a specific scope. Rolter organises resources in a four-level hierarchy:

<Steps>
  <Step title="Org">
    The top-level container for all your teams, projects, and virtual keys. An org-level admin can manage everything beneath it.
  </Step>
  <Step title="Team">
    A named group within an org — typically a product team or business unit. Teams own one or more projects.
  </Step>
  <Step title="Project">
    The primary unit of work. Routes, virtual keys, and budgets are attached to projects. Most day-to-day administration happens at this level.
  </Step>
  <Step title="Virtual Key">
    Individual API credentials. Budgets and rate limits can be scoped to a single key for fine-grained per-client control.
  </Step>
</Steps>

## Assigning roles per scope

A user can hold a different role at each scope level. For example, a developer might be a **member** in their team's project but only a **viewer** in the shared infrastructure project. Permission checks resolve the most specific membership for the resource being accessed.

```text
Alice  →  admin   at  org: acme
Bob    →  member  at  project: payments-api
Carol  →  viewer  at  team: platform
```

<Note>
  The control plane enforces RBAC for all management operations. The gateway data plane does not consult roles — it checks that the incoming virtual key exists, is active, and is allowed to access the requested model, and that the model belongs to the key's own organization. The one place the two meet is an access profile's [model and route policy](#model-and-route-policy), which does reach the gateway.
</Note>

The most specific grant wins even when it is the lower role: an org admin who is also a viewer on one project is a viewer there. Two grants at the same scope resolve to the higher of the two.

### Teams

**Governance → Teams** draws one card per team: its **People**, **Projects**, **Budget** and **Access profiles**, and who administers it. **People** counts each account once across the team and its projects, not only the grants made on the team itself, so a team whose people all work at project scope is not shown as empty; org-wide grants belong to no team and are not counted. The administrators are named by display name or email, the first one plus "+N" when there are several, and "none yet" when nobody holds admin on the team. A budget that could not be read says **unavailable** rather than looking like a team with no budget, and a team with several budgets shows the first and "+N more". A count the screen has not finished reading is a skeleton, never a zero.

**Open** on a card shows the team in a sheet: who holds a role on the team, each project of the team with who holds a role on that project, and links to **Users** (filtered to the team) and to **Budgets & Limits** opened on the team's scope (`/budgets?scope_type=team&scope_id=<team id>`). The sheet's **Delete** is the same action as in the scope switcher: deleting a team is an org admin's, the confirmation names the team and how many projects go with it, and everything scoped to those projects, such as routes, virtual keys and budgets, and the team's role grants are removed with it. The sheet's **Rename** changes the team's name through `PUT /api/v1/teams/{id}` with `{"name": "..."}`. The name must be unique within the organisation (a taken name answers `409` `name_taken`, shown under the field) and not empty. Renaming is the `team:update` capability, decided at the team: an admin of the team can rename it, while creating and deleting teams stay the organisation admin's. The audit log records `team.update` with the old and new name. Each team card also has a link to **Users** opened on that team (`/users?team=<team id>`).

### Managing roles on the Users screen

**Governance → Users** lists everyone holding a role in the selected organization, and the **Roles** column shows each of their grants on its own line: the role, then the scope it applies at, with teams and projects named. A scope the dashboard cannot name (the team or project was deleted, your account cannot see it, or its list failed to load) is marked **Unresolved scope**. A grant that came from your identity provider is marked **from SSO** or **from SCIM**.

A superadmin also sees the accounts that belong to no organization at all, such as the first administrator `rolter-seed --admin-email` creates, so they can find their own account here to edit it. The dashboard asks for them with `GET /api/v1/orgs/{org_id}/users?include_unassigned=true`; the parameter adds only accounts with no role anywhere, never another organization's people, and for anyone who is not a superadmin it changes nothing. Everyone else changes their own password in the **Password** panel of **Settings → Account & keys**, see [Your own password](#your-own-password).

Each row leads with the person's **display name** and shows the email beneath it; an account with no name shows the email alone, and the search box matches either. The person's **bio**, when they wrote one, follows as a line saying who to ask about what. Everyone sets their own under **Account → Profile**: a display name of up to 80 characters on one line and a bio of up to 500 characters, either of which can be cleared by emptying the field. The rail's account menu shows the same name, falling back to the email. On an account provisioned over SCIM the name is read-only (_Your name is managed by your identity provider_) and the bio stays editable. `PATCH /api/v1/me/profile`.

- **Grant a role.** The **+** at the end of a person's row opens a sheet with two fields: **Scope** (the whole organization, any team, or any project in any team, picked by name) and **Role**. If the person already holds that role at that scope, the sheet says so and does not send it. `POST /api/v1/orgs/{org_id}/memberships`.
- **Revoke a role.** The **×** button beside a grant asks first. The confirmation names the person, the role and the scope, and says what they lose. It works this out the same way the control plane resolves a role: either no other grant of theirs reaches that scope, or another grant takes over there, which can be a wider role than the one you revoked. It also tells you when this is their last role in the organization (they drop off the list, but the account is kept), when the grant came from SSO or SCIM (the identity provider grants it again at the next sign-in or sync unless you change the group mapping), and when it is your own role. `DELETE /api/v1/memberships/{id}`.
- **The last admin stays.** An organization always keeps one active org-scoped `admin` grant. Revoking or downgrading the last one, as an org admin or any other non-superadmin, is refused with `409` and the stable code `last_org_admin`; the message says to grant admin to someone else first. A grant held by a deactivated account does not count as a remaining admin, and a superadmin is exempt, so they can always repair an org. Two admins revoked at the same moment cannot both succeed: one is refused. Your identity provider is held to the same rule: SCIM refuses to deactivate the last admin, and an SSO or SCIM group change keeps the last admin grant until someone else holds admin (see [single sign-on](/security/single-sign-on#map-groups-to-roles) and [user provisioning](/security/user-provisioning)).
- **Change a role.** The **⇄** button beside a grant opens a confirmation where you pick the new role. rolter sends one request, `PUT /api/v1/memberships/{id}` with `{"role": ...}`: the control plane updates the grant in a single statement and writes one `membership.update` audit entry with the old and the new role, so the person is never left without a role and never holds two. It is authorized at the grant's own scope like a revoke, needs admin there, and is refused with the `last_org_admin` rule above when it would demote the organization's last admin. A grant that came from SSO or SCIM becomes a manual one when you change it, so the identity provider's next reconcile does not overwrite your choice; the group mapping still decides what it grants the next time it assigns the role. If the person already holds the picked role at that scope through another grant, the dashboard revokes the grant you are changing instead of duplicating the other.

Each control is disabled for callers who do not hold admin at the scope the dashboard is showing, and its tooltip names the role it needs. The server still checks every request, against your role at the grant's own scope. When it refuses with `403`, the dialog stays open with the server's message and says that revoking at that scope takes admin there or above. If a grant is already gone, for example revoked in another tab or removed by SSO reconciliation, the revoke counts as done and the list refreshes.

### Inviting people on the Users screen

**Invite user** opens a sheet that brings someone in at the organization, a team or a project.

- **Email and method.** The default method is an invitation link: the person chooses their own password, so nobody else ever knows it. **Set a password now** creates the account yourself and is the exception below. **Method**, **Scope** and the role field each carry an **(i)** note that says what the choice does.
- **Scope.** The whole organization, any team, or any project in any team, picked by name, the same picker as when you grant a role. The role field is named for the scope you pick (**Org role**, **Team role**, **Project role**) and lists **Admin**, **Member** and **Viewer**. An invitation is authorized at the scope it grants, so a team admin invites into their own team or its projects without an org admin. If the control plane refuses it, the sheet stays open with its message and says that inviting at that scope takes admin there or on a scope above it. `POST /api/v1/orgs/{org_id}/invitations` with `scope_type` and `scope_id`.
- **Inviting an address again replaces its pending invitation.** The email is compared without regard to case. The earlier link, expired or not, is revoked and stops working, and only the new link is live. The audit entry for the new invitation records the id it replaced.
- **An account created with a password** takes its role on the whole organization only, because that endpoint has no scope. The scope picker is locked to the organization and says so. Invite by link to choose a team or a project.
- **The link.** After **Invite** a dialog shows the link once, with what accepting it grants, and a **Copy link** button. If the control plane has a mail transport (see [Emailing invitations](#emailing-invitations)) the same link has also been emailed to the person, and the dialog says so; without one, nothing was sent and you send the link yourself. The page the person opens names the role the way you picked it, **Member** and not `member`, translated like the rest of the dashboard. The link cannot be shown again, so closing the dialog before you have copied it asks "Close without copying?"; Cancel keeps it open. If the browser will not let the page use the clipboard, which is common on a dashboard served over plain `http`, the dialog says the copy failed in a message that stays, and the link stays on screen, selected: copy it by hand with Ctrl+C or Cmd+C, which counts as copied. **Select value** selects it again.

Under the users table, **Pending invitations** lists every invitation that has not been accepted or revoked: the address, the role, the scope by name, who sent it (by address, as the control plane resolves it on each row, so a team admin sees the sender even when that person is outside the users they can list) and when it expires. A link works once and expires after seven days. An invitation past its expiry stays in the list, marked **Expired**, because it still holds its address: an organization keeps one unaccepted invitation per address, and inviting an address that already has one is refused with the control plane's own message. Revoke the listed one first. **Sent by** names the signed-in account that created the invitation, a superadmin included, even one that holds no role in any organization. It is read from the users list, so it says **not recorded** for an invitation sent with the admin token (or with no admin token configured) or by an account since deleted, and **unknown** for a sender you cannot list, such as a superadmin's account when you are an organization admin.

An org admin sees every invitation of the organization, a team admin the ones into the teams and projects they administer, and someone with neither sees no section.

The **×** at the end of a row revokes the invitation. The confirmation names the address, the role and the scope, and says the link stops working straight away; for an expired one it says revoking only frees the address. `DELETE /api/v1/invitations/{id}`. If the person accepted in the meantime, the control plane answers `404` because only unaccepted invitations can be revoked: the dialog says it is no longer pending and the list refreshes. Like the role controls, the button is disabled for callers without admin at the scope the dashboard is showing, and the server checks again at the invitation's own scope.

#### Emailing invitations

Set two environment variables on the control plane and an invitation is emailed to the invitee as well as handed to you:

| Variable           | Purpose                                                                                                                                                                                              |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ROLTER_SMTP_URL`  | The relay: `smtp://[user:password@]host[:port]` (STARTTLS when the server offers it, `?tls=required` to insist) or `smtps://host` for implicit TLS. Keep it in a secret, as it can carry a password. |
| `ROLTER_SMTP_FROM` | The sender, `noreply@example.com` or `Rolter <noreply@example.com>`.                                                                                                                                 |

Both are read at startup. The relay's address is held to the [egress policy](/configuration/config-file) like any other destination, and its certificate is verified against the host's trust store.

The link is returned to you whatever happens, so a relay that is down never stops you inviting anyone. The outcome is kept on the invitation: **Pending invitations** shows whether the link was **Emailed**, **Not emailed** with the reason (the mail server could not be reached, rejected the message, and so on), or **Not emailed: no mail is configured on this control plane**. A failed send is audited as `invitation.mail_failed`, a delivered one as `invitation.mail_sent`; neither records the address or the link. Without these variables nothing changes, and an air-gapped deployment never needs them.

If an email was lost or failed, **Pending invitations** has a resend button on each row that mails the same link again, at most once a minute; an invitation whose link was not kept (created before resending existed, or with no relay or no `ROLTER_KEK`) cannot be resent and has to be revoked and sent again. To find out whether the relay works before you need it, run `rolter check --connect`: it connects, negotiates TLS and authenticates, and names the stage that failed (`connect`, `tls`, `auth` or `relay`, the last for a server that answered but not as a mail server does) without echoing the address or the credential. The same check is `POST /api/v1/orgs/{org_id}/invitations/mail-check` ([API](/api/member-management#check-the-mail-relay)); it is not rate limited.

### Resetting a password

An administrator never sees or sets another person's password. **Reset password** (the key button on a row) asks first and then issues a **one-time link**, shown once in the same dialog an invitation link uses. You send it to the person yourself, since rolter does not send email, and they choose their own password at it. `POST /api/v1/orgs/{org_id}/users/{id}/password-reset`.

Issuing the link:

- ends every session the person has open and any sign-in halfway through its second-factor step, so whoever holds the old credential is cut off now;
- replaces the account's earlier reset link, so only the newest one works;
- leaves the old password valid until the link is used, so a mistaken click does not lock the person out. When the old password is known to be compromised, deactivate the account first;
- records a `user.password_reset` audit entry in the organization naming you, the person and how many sessions ended. The link itself is never recorded.

The link works once and expires after 24 hours. Using it stores the new password (at least 8 characters), ends the account's sessions again and **signs nobody in**: the person goes on to the ordinary sign-in, so a second factor they have armed still applies, and a link in the wrong hands cannot get past it. A reset never clears a second factor; for a lost one an operator uses `rolter mfa reset` on the host (see [two-factor authentication](/security/two-factor-authentication)).

Who may issue one:

| Caller                    | May reset                                                                                                                                                           |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Org admin                 | An active account with a local password that belongs to that organization **alone** and is not a superadmin. The role needed is `password_reset:create` at the org. |
| Team or project admin     | Nobody: the capability is checked at the organization.                                                                                                              |
| Superadmin or admin token | Any active local account in the organization named in the path, other superadmins included. Nobody resets their own password this way: use the Account page.        |

An account that belongs to other organizations as well, or that is a superadmin, is refused to an org admin with `403`, because a reset link lets its holder choose a password for the whole account, which reaches every organization it is in. An account that signs in through single sign-on has no local password to reset and is refused with `400`; so is a deactivated one.

### Blocking, removing and promoting people on the Users screen

An account reaches across every organization its person belongs to, so these controls need a superadmin: an admin sees them disabled, with a tooltip that says so. Each of them asks first, and every confirmation names the account.

- **Deactivate.** The button at the end of a row opens a confirmation. Deactivating blocks sign-in, ends every session the account has open, and stops the virtual keys the person minted for themselves at the gateway (see [When someone leaves](#when-someone-leaves)). The row's status turns **Deactivated**, the account also shows under the **Deactivated** tab above the table, and the button becomes **Reactivate**, with its own icon. Reactivating restores sign-in and those keys at once and asks nothing, because it only gives access back and deactivating again undoes it. `PUT /api/v1/users/{id}` with `deactivated`.
- **Reset password.** The key button at the end of an active row issues a one-time reset link for that person. See [Resetting a password](#resetting-a-password) below.
- **Superadmin.** The **Superadmin** switch in the edit sheet takes effect when you press **Save**. Turning it on asks first: the confirmation says a superadmin reaches every organization, team and project, and every deployment-wide setting and account, whatever roles they hold. Turning it off saves without asking, except on your own account. `PUT /api/v1/users/{id}` with `is_superadmin`.
- **Delete.** **Delete** at the foot of the edit sheet opens a confirmation that says the account leaves every organization and not only this one, with its roles and sessions, and that the keys it minted for itself are disabled rather than deleted. It cannot be undone. To block sign-in and keep the account, deactivate it instead. `DELETE /api/v1/users/{id}`.

The control plane refuses one thing: leaving the deployment without an active superadmin. Taking superadmin off the last active superadmin, deactivating it, or deleting it answers `409` with the error code `last_superadmin` and changes nothing; the message says to make another account superadmin first. A deactivated superadmin does not count as the one that remains, and neither does `ROLTER_ADMIN_TOKEN`, which is not an account — the token is refused the same way, so the rule does not depend on who asks. An identity provider deprovisioning the last active superadmin over SCIM is refused with a `409` as well. Everything else is allowed against yourself: you can deactivate or delete the account you are signed in with, or take superadmin off it, as long as another active superadmin remains. When the account is your own, the confirmation says you are signed out now, or lose the access you are using, and that only another superadmin can undo it. The dashboard does not warn about the last superadmin ahead of time, because its users list holds only the people with a role in the selected organization and cannot tell whether another superadmin exists; it shows the control plane's refusal instead.

If the control plane refuses the change, the confirmation stays open with its message and a line saying the change takes a superadmin. If the account was deleted elsewhere in the meantime, the control plane answers `404`: the confirmation says the account no longer exists and the list refreshes.

## Who sees which request logs

The request log, the usage and spend rollups and provider health are not only gated — each answer is filtered to the caller:

- A request-log row is visible to anyone holding a role at the row's org, team or project. A project viewer sees that project's rows, an org viewer the whole org's, and nobody sees another org's.
- The request and response **bodies** that [payload capture](/observability/log-settings#payload-capture) stored are shown to **members and admins** of the row's project. A viewer gets the row with the bodies withheld, and the log says so.
- A project admin can let the project's **viewers** read the bodies too: **Viewers can read captured payloads** in the project's settings (**Project settings** in the project's **⋯** menu in the scope switcher), or [`PUT /api/v1/projects/{id}/settings`](/api/project-settings). It is off by default.
- Provider health follows provider access: an org-level role in the provider's org, or a role on a project or team whose routes target the provider — an engineer sees the health of the upstreams behind their own routes, and nothing else about those providers. Each health record carries the org that owned the provider when it was written, and the match is on that org, so a provider name another org used and deleted never brings its history to the next org that creates it. Records with no org are visible only to unrestricted callers (the admin token and superadmins): those of providers defined in the gateway's own config file, and, after upgrading, those written before the org was recorded. Until they age out (90 days), an org viewer's health history starts empty at the upgrade.
- The admin token and superadmins see everything; so does every caller in [open mode](#open-mode-running-with-no-admin-token). A caller with no valid credentials gets `401`.
- A control plane with an admin token but no database has no accounts, so the admin token is the only credential these routes accept, and a request without it (or with a wrong one) is answered `401` with `code: "admin_token_required"`. The dashboard's sign-in card asks for the token there; see [signing in](/concepts/dashboard#signing-in).

## Organizations are isolated

A virtual key reaches only its own organization's routes, providers and provider groups, however the request names the model: a route name, `provider-slug/model`, or `group-slug/model`. `GET /v1/models` lists only those. For the key, another organization's model does not exist: the answer is `404` code `model_not_found`, the same as for a model nobody configured, so it does not confirm that the model exists. It also never hides one of the key's own models: if another organization has a route named `edge/gpt-4o`, a key whose organization owns the `edge` provider still reaches that provider.

The dashboard's own reads follow the same boundary. `GET /api/v1/models` and `GET /api/v1/config` answer every signed-in account, but list only the models, route targets, providers, groups, budgets and rate limits of the organizations (and, for a project-scoped row, the projects) the account holds a role in. A superadmin sees all, and rows defined in `rolter.toml` belong to the deployment and are listed for everyone. `GET /api/v1/config/problems` is cut the same way: its lines name only rows you can see, plus the deployment-wide ones.

The same boundary holds when you configure things: a route target or a provider-group member must be a provider of the route's own organization, and an id from another organization is answered with `404`, as if it did not exist. Route names and provider names are unique across the whole deployment for now, and provider slugs and provider group slugs share one namespace. A name another organization already uses is refused with `409 Conflict`, without naming that organization, and so is a route name that is another organization's `provider-slug/…` or `group-slug/…` address, or `fake-llm`. That last check runs one way: a provider or group created later with the slug of another organization's existing `slug/…` route is accepted. Your organization's keys reach your provider at that address, while keys from a config file, which carry no organization, reach the other organization's route.

Within an organization, a route is visible to every key by default. An admin can narrow it to the project it lives in — **This project** under a model's **Access & permissions** — or to listed teams and keys. **This project** narrows the route only: a provider stays reachable org-wide as `provider-slug/model` unless the provider itself is scoped to a project, which you set on its own sheet ([project-scoped providers](/concepts/providers-and-routes#project-scoped-providers)). See [per-model advanced settings](/concepts/providers-and-routes#per-model-advanced-settings).

Keys and routes declared in the gateway's own config file belong to the deployment rather than to an organization: those routes are available to every key, and those keys reach every route.

## What each person can list

Lists answer with what you can reach instead of refusing outright. A project member who holds no role at the team or org still sees their own team and project in the scope switcher, and the dashboard opens on their project; a team admin sees and manages the people, memberships and invitations of their own team. Deleting a team takes an organization admin, as creating one does, and deleting a project takes an admin of its team; the admin of the team or project being deleted is refused with `403`.

| List                                     | A role at the level above          | A role only further down                                   |
| ---------------------------------------- | ---------------------------------- | ---------------------------------------------------------- |
| Organizations                            | Superadmins see every organization | The organizations you hold any role inside                 |
| Teams of an organization                 | Every team                         | The teams you hold a role in, or inside                    |
| Projects of a team or organization       | Every project                      | The projects you hold a role in, or read through your team |
| Users and memberships of an organization | Everyone                           | The people in the teams and projects you can read          |
| Invitations of an organization           | Every invitation                   | The invitations into the teams and projects you administer |

Someone with no role anywhere in an organization gets `403` for its lists, and nobody learns the name of an organization they do not belong to. Invitations need the admin role, the other lists viewer.

## When someone leaves

Deactivating a person — from the [Users screen](#blocking-removing-and-promoting-people-on-the-users-screen), `PUT /api/v1/users/{id}`, or your identity provider through SCIM — signs them out everywhere and stops the virtual keys they minted for themselves at the gateway on the next config refresh. Removing their last role that reaches a key's project does the same for that key. Reactivating them, or giving the role back, brings the same keys back.

Deleting the account disables those keys for good: they stay listed on the project, disabled, and an admin has to re-enable one deliberately.

Keys an admin minted for a project — the ones applications use — have no owner and are never affected by who created them, so offboarding a person cannot take an application down. The audit entry for a deactivation, reactivation or deletion records how many personal keys it affected.

## Account events in the audit log

Sign-ins, failed sign-ins, lockouts, second-factor changes, break-glass resets and account edits belong to a person rather than an organization. An organization's audit log (**Governance → Audit Logs**, or `GET /api/v1/orgs/{org_id}/audit-log`) shows them for the organization's own people — anyone holding a role in it, its teams or its projects — and never for anyone else's. Filter on the `auth.*` actions to find them. Only account events are shown this way, the `auth.*` and `user.*` actions: a change to a deployment-wide setting belongs to no organization and is read through the deployment-wide log below, whoever made it. Deleting an account is recorded in the audit log of every organization the account belonged to.

Account events of someone with no role in any organization — a superadmin's own sign-ins, for instance — and attempts against an email address nobody registered belong to no organization's audit log. A superadmin reads them, together with every other row, through the deployment-wide `GET /api/v1/audit-log`. It takes the same query parameters as the per-organization read (`limit`, `actor`, `action`, `target_type`, `start_at`, `end_at`, `cursor`, `direction`, `include_total`) and answers with the same page shape, newest first, each page carrying the `next_cursor` for the one after it. On both reads `start_at` is inclusive and `end_at` exclusive, as in the analytics reads, so a row stamped exactly at `end_at` belongs to the next window and adjacent windows never return it twice. Everyone else, organization admins included, gets `403`, except a security auditor, who may read it (see [Deployment roles](#deployment-roles)). The `deployment_audit_log:read` capability in `GET /api/v1/rbac/matrix` lists `security_auditor` as the deployment role that reaches it.

In the dashboard, a superadmin or a security auditor sees a scope switch on **Governance → Audit Logs**: **Whole deployment** reads that endpoint with the same filters and paging as the organization view. Rows with no organization are marked **No org**, and a sign-in attempt against an unregistered address shows **Unknown address** as its actor. Nobody else is offered the switch. A superadmin's **Actor** column names accounts that hold no role anywhere, such as the operator `rolter-seed --admin-email` creates, by e-mail, and the actor filter lists them too. In **Whole deployment** the column names the actor of any row, whichever organization the account belongs to, and the filter lists every account in the deployment by e-mail. An actor the dashboard cannot name, such as an account that has since been deleted, shows **Not in this org** (**Unknown account** in the whole-deployment view), with the whole id on hover and in the open entry; to filter on one, type its account id into the filter.

**Show** on a row opens the entry directly under it, headed with its action, target and time. Every id in it (entry, actor, target, organization) is shown whole with a copy button, and the time is also given in UTC. Rows with no actor read **system**. The time column carries the time zone, in the account's chart time zone.

## Exporting the audit log

**Export CSV** and **Export JSON** on **Governance → Audit Logs** download the log under the filters in force (actor, action, target type and time window) rather than the 25 rows on screen. The control plane builds the file and streams it, so there is no row cap: a year of changes arrives whole, newest first, and the control plane holds one page of it at a time however long the log is.

The same file is available to an API client:

- `GET /api/v1/orgs/{org_id}/audit-log/export?format=csv|json` for an organization's log, gated and scoped exactly like `GET /api/v1/orgs/{org_id}/audit-log`. It needs `audit_log:read` in that organization, and it returns the same rows, account events of the organization's own people included, and never more.
- `GET /api/v1/audit-log/export?format=csv|json` for the whole deployment, for a superadmin or a security auditor like `GET /api/v1/audit-log`.

Both take the page read's filters, `actor`, `action`, `target_type`, `start_at` and `end_at`, with the same bounds, and ignore `limit`, `cursor` and `direction`. A missing or unknown `format` is a `400`. If a read fails part way, the response ends in an error instead of a clean close, so a truncated file is never mistaken for a complete one.

Each row has `at` (UTC, ISO 8601 with a `Z`), `actor_email` (empty when the account no longer exists), `actor_user_id`, `action`, `target_type`, `target_id`, `org_id`, `entry_id` and `detail` (a JSON string in the CSV). The CSV defuses spreadsheet formulas: a cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return gets a leading `'`. The JSON is one array of row objects. The file is named `audit-log-<org>-<from>_to_<to>.<csv|json>`, where `<from>` is `all` without a lower bound and `<to>` is the upper bound or today.

Starting an export is itself audited: an `audit_log.export` entry in the organization (no organization for the deployment-wide export) names who ran it and the format and filters it used. It is written before the first byte is sent, so an export that is abandoned part way is still on record.

A control plane that predates the endpoint answers `404`; the dashboard then falls back to reading the pages itself, 500 rows at a time, and stops at the newest 10,000 rows with a notice.

The actor of a deployment-wide row can belong to any organization. `GET /api/v1/users` lists every account in the deployment, ordered by e-mail, so an API client can name any of them, and it is what the dashboard's **Whole deployment** view reads to do the same; see [Listing every account in the deployment](/api/deployment-users). Its `deployment_user:read` capability is for a superadmin or a security auditor, like `deployment_audit_log:read`.

## Who an entry names as the actor

Each audit entry records the account that took the action: the **Actor** column in the dashboard, `actor_user_id` in the API. What is recorded depends on how the caller signed in, and it is the same for every audited action:

| Caller                                       | Recorded as the actor                                                                                                                           |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| A signed-in member, whatever their role      | Their own account                                                                                                                               |
| A signed-in superadmin                       | Their own account, including a superadmin who holds no role anywhere, such as the operator `rolter-seed --admin-email` creates                  |
| The admin token (`ROLTER_ADMIN_TOKEN`)       | Nobody. The token is a credential whoever holds it can use, not a person                                                                        |
| Open mode (no admin token configured)        | Nobody. Nobody signed in                                                                                                                        |
| An identity provider, through its SCIM token | Nobody in the actor column. The entry's detail carries the `scim_token_id` that made the change                                                 |
| A virtual key                                | Not applicable. A virtual key calls the gateway, which writes request logs rather than audit entries, and the control plane does not accept one |

A superadmin's own account is therefore on every entry they cause, which is what lets you ask who changed a setting or issued a token. The same account fills the other "who did this" fields: `created_by` on a SCIM token, `invited_by` on an invitation, and `revoked_by` on an MCP consent that was revoked. The admin token and open mode leave those empty as well.

Changes to deployment-wide settings — feature flags, security, runtime, compatibility and adaptive routing policy, log settings, model defaults, client settings, connectors, deployment-wide alert channels and rules and cluster nodes — belong to no organization, so a superadmin reads them through the deployment-wide `GET /api/v1/audit-log`. Pass `actor=<account id>` to list everything one superadmin did. Alert channels and rules that belong to an organization (see [Alerting](/observability/alerting#scopes-and-ownership)) are the exception: their changes (`alert.channel.*`, `alert.rule.*`) carry that organization, so they are in its audit log and export, and in the deployment-wide log too. The rest never appear in an organization's audit log, even when the superadmin who made them also holds a role in that organization: an organization's log carries its own entries and the account events of its people (the `auth.*` and `user.*` actions), and nothing else that has no organization.

## Ask the server what a role can do

Rolter publishes its own permission table, so you never have to keep a copy in sync with the deployment:

| Endpoint                                                  | Answers                                                                                                                                                                                                           |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/rbac/matrix`                                 | For every resource, the minimum role each of read / create / update / delete takes — or that the action is reserved for a superadmin, open to any authenticated caller (`authenticated_only`), or does not exist. |
| `GET /api/v1/rbac/effective?org_id=&team_id=&project_id=` | What _you_ may do at that scope, resolved from your own memberships.                                                                                                                                              |

A resource's actions usually share one scope, but a list can sit deeper than the writes beside it. `GET /api/v1/rbac/matrix` then carries a `scope` on that action: the project and user lists (`project:read`, `user:read`) are decided at the project, because they answer with the rows you reach through a team or project role, while creating a project (a team admin's) or a user (an organization admin's) keeps the resource's own scope. `effective` follows, so a project admin is told they may read the lists and not create from them. No one gains or loses access; the answer only matches what the server already did.

Providers and provider groups can be scoped to one project, so a project admin is answered `yes` to creating, editing and deleting them when the query names their project, and the dashboard offers those controls on that project. An org-wide provider or group stays an org admin's: a project admin who tries it anyway gets `403`.

Both read the same table the control plane enforces with, so the answer is never a guess: a request that `effective` says you cannot make returns `403` if you make it anyway.

A resource's sub-resources sit at the same bar as the resource itself. A route's complexity-routing tiers are the one part of a route with their own endpoint, and they follow that rule: reading them takes `route:read`, replacing them takes `route:update`. So a viewer sees every policy on the **Complexity Router** screen for the routes it can already list, and changes none of them.

### Reading it in the dashboard

**Roles & Permissions** renders `GET /api/v1/rbac/matrix` directly, so the grid on screen is the deployment's own rule set rather than a copy of it. One row per resource, grouped by the scope the resource lives under (deployment-wide, organization, team, project); one column per role, with the built-in three first and your org's custom roles after them, ordered by the base role each extends.

Every signed-in role can open **Roles & Permissions**, since the matrix describes what roles can do rather than anyone's data. Your organization's custom roles appear on it for anyone holding a role in that organization.

Budgets and rate limits follow the scope chain: anyone holding a role on a project, a viewer included, can read the budgets and rate limits set on that project, on its team and on its organization, the caps that throttle their own keys. Another project's or team's are not shown, and creating, changing or deleting a cap stays an admin's of the scope it is set on.

Each cell carries four chips, one each for read, create, update and delete in that order, and each chip says one of five things:

| Chip                             | Means                                                                                                                                                                        |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The action's letter (R, C, U, D) | Allowed. The role's rank clears the minimum, or the action needs no membership at all.                                                                                       |
| The letter in a dashed border    | Granted explicitly by that custom role, on top of its base role.                                                                                                             |
| A lock                           | Superadmin only. No org role reaches it, and no custom grant can either.                                                                                                     |
| A cross                          | Not allowed. The role exists at this scope but is not senior enough.                                                                                                         |
| `–`                              | The action does not exist for that resource — an audit log is append-only, an org has no update route. Nobody can perform it, including a superadmin, so it is not a denial. |

The chip's colour repeats its state but never carries it alone, so the grid reads the same in greyscale, and the legend under it names all five. The grid is a table: a screen reader announces the role and the resource with each cell and reads every chip as its action and state, for example "Create: not allowed".

The rest of the dashboard asks `GET /api/v1/rbac/effective` for your own answer and shapes itself around it, so a control you may not use is not offered rather than refused after the click. A create button your role does not reach is disabled and names the role it would take; so is every per-row control beside it — the edit, delete, retire, revoke, drain and enabled toggles on each row of Providers, Virtual Keys, Routing Rules, Users, Alerting and the rest carry the same check, so a read-only account sees a list it can read rather than a list of buttons that all end in a refusal; a navigation group whose every screen you may not read is hidden instead of leading to a wall of refusals; and a deployment-wide settings screen tells a non-superadmin so immediately, without the pause of a request that was always going to be denied. This is presentation, not enforcement — the control plane still checks every request, so a refusal you route around by hand is still a `403`. When the dashboard cannot get an answer (an older control plane, a network blip) it shows every control as before and lets the server have the last word.

The matrix has a **Filter resources** field above it, since a real build lists about fifty resources. The built-in columns carry the role's translated name, not the wire value. Member counts under them come from the org's live memberships and count each account once however many scopes it holds the role at (an org admin and a project admin both count under **Admin**), which the label says. Custom roles have none: they are handed out through [access profiles](#custom-roles-and-access-profiles), not memberships, and the column says so. A custom role naming a `resource:action` this build does not define is reported above the grid — it grants nothing, and an operator who downgraded Rolter should be able to see why a role went quiet.

<Note>
  Deployment-wide settings — feature flags, runtime, logging, compatibility and adaptive-routing policy, cluster nodes, security settings, deployment-wide alerting and the model pricing catalog — sit outside the org/team/project hierarchy. No org role reaches them; they require the deployment admin token or a superadmin account (or a [deployment role](#deployment-roles)). Alert channels and rules that belong to an organization or a narrower scope are the exception: they take admin at that scope. The same is true of editing or deleting a user account, which spans every org that user belongs to.
</Note>

## Custom roles and access profiles

The three built-in roles are a floor, not the limit. An organization can define its own **custom roles**: pick a base role, then add the exact `resource:action` pairs it should also allow.

```bash
curl -X POST https://control.example.com/api/v1/orgs/$ORG_ID/custom-roles \
  -H "Authorization: Bearer $ROLTER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Provider Wrangler",
        "base_role": "member",
        "grants": [{"resource": "provider", "action": "create"}]
      }'
```

A grant can only **widen**. A custom role never removes what its base role already allowed, so existing admin/member/viewer behaviour is untouched and nothing needs migrating.

Custom roles are not handed to people one at a time. You compose them into an **access profile**, which pairs each role with the org, team or project it applies at, and assign that profile to users or teams:

```bash
# "auditor across the org, and deploy admin on one project"
curl -X POST https://control.example.com/api/v1/orgs/$ORG_ID/access-profiles \
  -H "Authorization: Bearer $ROLTER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Platform On-Call",
        "roles": [
          {"role_id": "'$AUDITOR_ROLE'",  "org_id": "'$ORG_ID'"},
          {"role_id": "'$DEPLOY_ROLE'", "project_id": "'$PROJECT_ID'"}
        ]
      }'

curl -X POST https://control.example.com/api/v1/access-profiles/$PROFILE_ID/assignments \
  -H "Authorization: Bearer $ROLTER_TOKEN" \
  -d '{"user_id": "'$USER_ID'"}'
```

Scope is honoured exactly as it is for memberships: a grant attached to one project authorizes that project, not the whole org.

### Managing them in the dashboard

Everything above is also on screen, so a role does not have to be defined with `curl`.

**Roles & Permissions** carries two tabs. The first is the [permission matrix](#reading-it-in-the-dashboard). The second, **Custom roles**, lists the org's own roles with the base role each extends, how many pairs it grants, and which access profiles compose it — that last one is what a delete would have to be detached from first.

Editing a role says who the save reaches: the sheet names the access profiles that compose it ("Saving changes what everyone holding this role gets through Support"), or says that none does yet. Creating or editing a role opens a grant grid: one row per resource the deployment guards, four columns for read, create, update and delete. The grid is built from the same `GET /api/v1/rbac/matrix` response as the matrix tab, never from a list written into the dashboard, so it can only ever offer pairs this build actually defines. Two kinds of cell cannot be ticked:

- a pair the resource does not have — an audit log has no update — shows a dash rather than a checkbox, because "there is no such action" is a different answer from "you may not grant it";
- a **deployment-wide** action shows a checkbox that is disabled. No custom role can reach one, for the reason in the note above, and hiding it would leave an operator hunting for a row that is deliberately out of reach.

<Note>
  A role can also carry grants naming a resource this build no longer defines — after a downgrade, say. They are not on the grid, since there is no row to tick them on, but the sheet counts them and saving keeps them exactly as they are. Editing a role for an unrelated reason never quietly rewrites it.
</Note>

**Access Profiles** reads `GET /api/v1/orgs/{org_id}/access-profiles?include=roles,assignments,policy` for the whole list in one request — each profile carries the same `roles`, `assignments` and `policy` that `GET /api/v1/access-profiles/{id}` returns — so a card shows the roles composed into it, everyone it reaches, and the model policy it carries — the policy in particular has no list endpoint, and before this it could be written and never seen again. The same sheet creates and edits: name, the custom roles to compose, and the four allow/deny pattern lists, saved in one request. A pattern is an exact name or a prefix ending in `*`; a `*` anywhere else is a literal character that matches nothing, so the sheet flags such a line (`o1-*-mini`, `*-preview`) and refuses to save until it is fixed. In an allow-list one such line would otherwise block every model for everyone the profile reaches.

Each card lists, by name, the users and teams the profile reaches. The **Assign** button on a card opens a sheet where you choose **User** or **Team** and pick one from the organisation's own list; accounts and teams the profile already reaches are not offered again. A team assignment reaches every member of the team, current and future. The remove button beside a name asks for confirmation before it takes the assignment away, and the delete confirmation for a whole profile says how many users and teams lose it. Both need the admin role in the organisation (`access_profile_assignment:create` and `:delete`); a viewer sees who a profile reaches and finds the buttons refused. Every assignment is written to the audit log.

The same assignments are readable from the other side. The **Users** screen has an **Access profiles** column and each **Teams** card an **Access profiles** line naming the profiles that person or team holds, so "what can this person do and why" is answered where you look the person up. The plus button there opens a sheet that picks a profile; the profiles already held are not offered again. The remove button beside a profile asks for confirmation. Both call the same assignment endpoints as the Access Profiles cards and need the same admin role (`access_profile_assignment:create` and `:delete`). Both screens read the assignments in one request, `GET /api/v1/orgs/$ORG_ID/access-profile-assignments` (add `?user_id=` or `?team_id=` for one subject), however many profiles the org has; it needs the same read access as listing a profile's assignments and shows only the org's own profiles. Profiles a person reaches through a team are not listed on their row. A list that could not be loaded says so instead of reading as "no access profile". When you are signed in as a superadmin, the picker on an Access Profiles card also offers accounts that hold no membership anywhere, such as the first administrator the seeder creates.

Ticking a role opens **Where _role_ applies** beside it, so the `curl` above — "auditor across the org, and deploy admin on one project" — is one profile written on screen:

- The default is the whole organization, which is the scope the control plane assumes when a composition names none. A profile written without touching the picker is exactly the profile the sheet wrote before.
- The list covers the whole org: every team, and every project in any of those teams grouped under the team that owns it. A project outside the team the scope switcher currently shows needs no detour through the switcher. An organization with no teams yet says so and offers only itself.
- A scope is never wider than the profile's own org, whatever is picked — the control plane validates it on the way in.
- An existing composition opens at the scope it was stored with, so editing a profile's name cannot quietly widen a project-scoped role, and a card lists any role that is narrower than the org as `role on scope`. A scope the dashboard cannot name — the team or project was deleted, or your account cannot see it, or the list failed to load — is marked **Unresolved scope** rather than shown as a bare id, and hovering the chip gives the stored id to quote in a support request.

## Model and route policy

A profile can also carry a **model and route policy** — allow and deny lists for the models and routes its holders may reach. Entries are exact names or a trailing-`*` prefix glob (`gpt-*`), an empty allow-list means "no restriction", and deny always wins over allow. Where someone holds several profiles the lists are unioned, never intersected, so giving a person another profile can only ever widen their access.

It is reported on `GET /api/v1/rbac/effective` as `model_policy`, and the gateway enforces it. Because a policy belongs to a person while a request carries a key, rolter resolves the policy of each virtual key's **creator** and attaches it to that key. A denied model returns `403` with code `model_not_allowed`; a denied route returns `403` with code `route_not_allowed`.

### The same checks on every endpoint

Access to a model never depends on which endpoint or body format a client uses. Chat and text completions, the Responses and Messages APIs, embeddings, rerank, image generation, text-to-speech, audio transcriptions and translations (`multipart/form-data` uploads), and Realtime WebSocket sessions all apply the same checks, in this order, before anything is sent to a provider:

| Check                                                   | Refused with                           |
| ------------------------------------------------------- | -------------------------------------- |
| The key's own model list and its creator's model policy | `403` with code `model_not_allowed`    |
| The route belongs to the key's organization             | `404` with code `model_not_found`      |
| The route's visibility: this project                    | `404` with code `model_not_found`      |
| The route's visibility: allowed teams or keys           | `403` with code `model_not_allowed`    |
| The key creator's route policy                          | `403` with code `route_not_allowed`    |
| The key's provider list leaves no provider on the route | `403` with code `provider_not_allowed` |

A refused request is never forwarded to a provider and never opens an upstream Realtime socket.

The same checks apply again when a key retrieves, cancels, deletes or lists the input items of a stored response (`/v1/responses/{id}`), using the model and route the response was created on. Revoking a key's access to a route therefore also cuts off the responses it already created there, and if the route itself has been deleted those calls are refused with `403` code `route_not_allowed`. Recreating the route restores access. Only the key that created a response can reach it at all; any other key gets `404`.

<Note>
  A key's own model list and its creator's policy are both applied, and neither widens the other. A key that names `gpt-4o` still cannot reach it if its creator's profile denies it — and a profile that allows `gpt-4o` does not add it to a key scoped to something else.

Keys with no creator — those made by an admin on someone's behalf, or defined in the config file — carry no policy at all, since there is no person whose profiles could apply. Restrict those with the key's own model list.

</Note>

Policy changes propagate to the gateway the same way provider and route changes do, on the next config refresh rather than instantly. Assigning or revoking a profile, editing its policy, and adding or removing someone from a team all trigger that refresh.

<Warning>
  Deleting a custom role that a profile still references returns `409` rather than quietly emptying that profile — detach it first. Deleting a *profile* does remove its assignments, since they mean nothing without it. Every change to a role, profile, assignment or policy is written to the audit log with its before and after, because all of them change what real people can do.
</Warning>

`GET /api/v1/rbac/matrix?org_id=` returns your org's custom roles alongside the built-in ones, to anyone holding a role in that organization, so the dashboard's matrix always reflects the current rule set.

## Open mode: running with no admin token

If `ROLTER_ADMIN_TOKEN` is not set, the control plane has **no authentication at
all**: every request to the management API (`/api/v1/*`) and to
`/internal/snapshot` is served as superadmin. Anyone who can reach the port can
read and rotate keys and change providers, routing and budgets.

This exists as a zero-credential local-development affordance, and Rolter keeps
it from becoming anything else:

- **It cannot bind a public interface by accident.** With no admin token set,
  the control plane refuses to start unless every listener — the API port and
  `--internal-addr`, when split out — is on loopback. Binding `0.0.0.0` in this
  state exits with an error naming the variable that closes it.
- **It is loud.** Every start in open mode logs a warning naming what is
  unprotected, rather than only that a variable is missing.
- **It is visible.** The dashboard shows a persistent banner for as long as the
  control plane it is talking to is unauthenticated.

Set the token to close it:

```bash
export ROLTER_ADMIN_TOKEN="$(openssl rand -hex 24)"
```

## Your own profile

Any signed-in account, a viewer included, can set a display name and a short bio with `PATCH /api/v1/me/profile`. The route writes only the caller's own row, so it needs no role and does not widen `user:update`, which stays superadmin-only.

```bash
curl -X PATCH "$ROLTER_URL/api/v1/me/profile" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"display_name": "Grace Hopper", "bio": "Ask me about the nightly batch."}'
```

- `display_name` is 1 to 80 characters and `bio` up to 500, after surrounding whitespace is trimmed. Control characters are refused (a bio may contain line breaks) and so is a value that is only whitespace.
- An omitted field is left alone; `null` or `""` clears it.
- `GET /api/v1/auth/me` returns both fields on `user`, and the users list returns them for admins.
- An account provisioned over [SCIM](/security/user-provisioning) has its display name owned by the identity provider: changing it answers `409`, and `display_name_managed` on `/api/v1/auth/me` is `true`. The bio is still editable. Accounts signing in over OIDC keep an editable name.
- Each change is written to the audit log as `user.profile.update`, listing the field names that changed but never the bio text.

## Your own password

Any signed-in account with a local password can change it with `POST /api/v1/auth/password`, a viewer and an account with no role included. Like the profile route it writes only the caller's own row, so it needs no role and does not widen `user:update`, which resets anyone's password and stays superadmin-only. The route asks for the current password, goes through the same failed-attempt throttle as a sign-in, and ends the account's other sessions while keeping the one it was made from. An account that signs in through single sign-on only has no local password: `GET /api/v1/auth/me` reports `has_local_password: false` and the route answers `409 no_local_password`. The dashboard offers it as the **Password** panel on **Settings → Account & keys**, and says why instead when the account has no local password. The request, every error and the audit entries are on [Change your password](/api/change-password).

## Your own preferences

Any signed-in account keeps a small preferences document on the server, so the dashboard language, default scope, default Playground model and chart time zone follow the account to another browser. `GET /api/v1/me/preferences` reads it and `PUT /api/v1/me/preferences` saves it. Like the profile route it writes only the caller's own row and needs no role; there is no route that reads anyone else's.

```bash
curl -X PUT "$ROLTER_URL/api/v1/me/preferences" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"language": "ru", "default_playground_model": "gpt-4o", "chart_time_zone": "Europe/Berlin"}'
```

- Keys: `language` (`en` or `ru`, the dashboard's catalogs), `default_org_id`, `default_team_id`, `default_project_id` (UUIDs), `default_playground_model` (up to 200 characters) and `chart_time_zone` (an IANA name such as `Europe/Berlin` or `UTC`; the server checks its shape and the browser rejects a name it does not know). A key that is missing or `null` means "use the deployment default". An unknown key is refused with `400`.
- `PUT` replaces the whole document: a key you leave out is cleared. Send every value you want to keep.
- Preferences never widen access. A default scope you cannot read is stored as written, because access can change later, but `GET` also returns `effective_default_scope` (`org_id`, `team_id`, `project_id`): your stored default if you can still read it, otherwise the first scope you can read, otherwise `null`. The dashboard opens on that and never on the raw `default_*_id` fields.
- Each change is written to the audit log as `user.preferences.update`, listing the key names that changed but not their values.
- The dashboard edits all of this on **Settings → Preferences**, and the language picker in the sidebar saves to the same document. The saved language, Playground model and time zone apply on every browser you sign in on; a scope you pick in the scope switcher wins for the session, and your saved default is used the next time you sign in. A time zone the browser does not recognise is ignored and times follow the browser's own zone.

## Saved views

Any signed-in account, a viewer included, can save the filters it has built on **LLM Logs** or the **Dashboard** as a named preset and apply it again on any browser. A preset is private: every route reads and writes only the caller's own presets, there is no sharing, and another account's preset id answers `404` exactly as an id that does not exist does.

| Method and path                      | Does                                                                        |
| ------------------------------------ | --------------------------------------------------------------------------- |
| `GET /api/v1/me/saved-views`         | list your presets, oldest first; `?surface=llm_logs` or `dashboard` narrows |
| `POST /api/v1/me/saved-views`        | save one: `{"surface", "name", "filters"}`                                  |
| `GET /api/v1/me/saved-views/{id}`    | read one                                                                    |
| `PATCH /api/v1/me/saved-views/{id}`  | rename (`name`) and/or replace the whole filter set (`filters`)             |
| `DELETE /api/v1/me/saved-views/{id}` | delete one                                                                  |

```bash
curl -X POST "$ROLTER_URL/api/v1/me/saved-views" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"surface": "llm_logs", "name": "Errors on gpt-4o", "filters": {"window": "7d", "status": "error", "model": "gpt-4o"}}'
```

- `surface` is `llm_logs` or `dashboard` and cannot be changed afterwards. A name is 1 to 80 characters, is trimmed, and is unique per surface ignoring case (`409` otherwise). You may keep 50 presets per surface (`409` beyond that).
- `filters` is an object whose keys are allow-listed per surface and mirror the query parameters the screen sends; an unknown key or a value of the wrong type is refused with `400`. **LLM Logs**: `window`, `status` (`all`, `error` or `success`), `model`, `key` (a virtual key id), `business_unit` and `customer` (lists of ids). **Dashboard**: `window`, `bucket` (`hour`, `day`, `week` or `month`), and the same `model`, `key`, `business_unit` and `customer` as LLM Logs, which the [Dashboard's analytics reads](/api/dashboard-analytics) apply. `window` is one of `24h`, `7d`, `30d`, `mtd` or `last-month`: a rolling window's name, not timestamps, so a saved "last 7 days" always means the seven days before you open it.
- A preset never widens access: it only remembers parameters, and the analytics routes still scope every row to what you can read.
- A preset can name a virtual key, business unit or customer you can no longer read, because your membership was removed or the row was deleted. Reads return the preset as stored in `filters`, the same set without those entries in `effective_filters`, and `unavailable`, a list of `{"filter": "key" | "business_unit" | "customer", "id": ...}` naming what was dropped, on either surface, so a screen applies the rest and says what it left out. Models and windows are never unavailable. Whether you can still read a key, unit or customer is decided by the same rule as the listings of those resources.
- Each create, update and delete is written to the audit log as `user.saved_view.create`, `user.saved_view.update` or `user.saved_view.delete` with the preset's id, surface and name, never the filter values.

### Self-service endpoints do not work in open mode

The `/api/v1/me/*` routes — the dashboard's **API Keys** screen — are per-user:
they list the keys _you_ minted and scope usage to _your_ account. Open mode has
no accounts, so there is no "you" and these endpoints cannot be served to
anyone. They return `401` with `code: "open_mode_no_session"`, which the
dashboard renders as an explanation of what to change rather than a load
failure.

This is distinct from an ordinary `401` (`code: "unauthenticated"`), which means
you are simply not signed in and signing in fixes it. Setting
`ROLTER_ADMIN_TOKEN` and creating a local account is what makes the self-service
screens work; the admin **Virtual Keys** screen manages keys without one.

<Warning>
  `--allow-open-mode` (`ROLTER_ALLOW_OPEN_MODE=1`) overrides the loopback
  refusal and serves an unauthenticated control plane on whatever interface you
  bound. There is no scenario in which that is right for a deployment reachable
  by anyone else — the flag exists so that a container or CI harness that must
  bind `0.0.0.0` can do so deliberately, on a network you control.
</Warning>

## Most-restrictive-wins

When evaluating budgets and rate limits, Rolter walks the scope chain from org down to the individual key and applies the most restrictive matching rule. This means an org-level budget acts as a hard ceiling even if a project-level budget is more generous — and a per-key rate limit can tighten throughput below what the project allows.

<Tip>
  Use this to your advantage: set a broad org-level budget as a safety net, then add project- or key-level limits to give individual teams or clients their own allocation. See [Budgets & Rate Limits](/security/budgets-and-rate-limits) for configuration details.
</Tip>

## User accounts

Rolter ships with **local user accounts** secured with email and password. You create and manage accounts through the control plane dashboard or API.

**OAuth2 / OIDC single sign-on** — with group-to-role mapping for identity providers such as Okta, Auth0, and Google Workspace — is available today, and **SCIM** provisions and deprovisions accounts from the same providers. See [Single sign-on](/security/single-sign-on) and [User provisioning](/security/user-provisioning). A first SSO sign-in gives an account that has no display name the one your identity provider sends; the user can still change it.

**LDAP / Active Directory sign-in** is available too: an org registers a directory, maps its groups to roles, and its users sign in with their directory password. See [LDAP](/security/ldap).

<Info>
  SAML sign-in is not available. If your organisation requires SAML-based authentication, reach out to discuss the enterprise roadmap.
</Info>
