Skip to main content

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.

admin

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.

member

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.

viewer

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.

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.

RoleForReaches
operatormaintaining the platformcluster 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_auditorreviewing itread 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:

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:

Org

The top-level container for all your teams, projects, and virtual keys. An org-level admin can manage everything beneath it.

Team

A named group within an org — typically a product team or business unit. Teams own one or more projects.

Project

The primary unit of work. Routes, virtual keys, and budgets are attached to projects. Most day-to-day administration happens at this level.

Virtual Key

Individual API credentials. Budgets and rate limits can be scoped to a single key for fine-grained per-client control.

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.

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, which does reach the gateway.

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.

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 and 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) 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:

VariablePurpose
ROLTER_SMTP_URLThe 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_FROMThe sender, noreply@example.com or Rolter <noreply@example.com>.

Both are read at startup. The relay’s address is held to the egress policy 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); 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).

Who may issue one:

CallerMay reset
Org adminAn 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 adminNobody: the capability is checked at the organization.
Superadmin or admin tokenAny 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). 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 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 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. 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. 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.

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). See 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.

ListA role at the level aboveA role only further down
OrganizationsSuperadmins see every organizationThe organizations you hold any role inside
Teams of an organizationEvery teamThe teams you hold a role in, or inside
Projects of a team or organizationEvery projectThe projects you hold a role in, or read through your team
Users and memberships of an organizationEveryoneThe people in the teams and projects you can read
Invitations of an organizationEvery invitationThe 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, 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). 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. 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:

CallerRecorded as the actor
A signed-in member, whatever their roleTheir own account
A signed-in superadminTheir 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 tokenNobody in the actor column. The entry’s detail carries the scim_token_id that made the change
A virtual keyNot 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) 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:

EndpointAnswers
GET /api/v1/rbac/matrixFor 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:

ChipMeans
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 borderGranted explicitly by that custom role, on top of its base role.
A lockSuperadmin only. No org role reaches it, and no custom grant can either.
A crossNot 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, 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). 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.

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.

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:

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

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:

CheckRefused with
The key’s own model list and its creator’s model policy403 with code model_not_allowed
The route belongs to the key’s organization404 with code model_not_found
The route’s visibility: this project404 with code model_not_found
The route’s visibility: allowed teams or keys403 with code model_not_allowed
The key creator’s route policy403 with code route_not_allowed
The key’s provider list leaves no provider on the route403 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.

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.

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:

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.

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

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.

  • 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 pathDoes
GET /api/v1/me/saved-viewslist your presets, oldest first; ?surface=llm_logs or dashboard narrows
POST /api/v1/me/saved-viewssave 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
  • 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 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.

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 for configuration details.

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

Info

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