Guides
Users, roles and limits
Who can do what in the management API and dashboard, how logins are protected and audited, and the per-project rate limits, budgets, metrics and retention.
Updated:
This page covers who can do what in the management API and dashboard, how logins are protected and audited, and the per-project rate limits, token budgets, metrics and retention that apply to the client API. Endpoint details are in the API reference.
Roles
The first-run account (ADMIN_USER) is an admin. Admins create further users in the
dashboard (Users tab) or via POST /admin/api/users. Every user has exactly one role:
| Role | Model connections, RAG stores, documents | Projects | API keys | Users, audit log |
|---|---|---|---|---|
admin |
full access | all projects, all metrics | own and everyone’s; only role that may delete one | full access |
editor |
create, edit, delete, upload, test, search | create (becomes a member); read, edit, delete, rotate key, metrics and members only for projects it belongs to | own; may create management keys |
list active users (/users/lite) |
viewer |
read and search only | read and metrics only for projects it belongs to | own gateway (sk-user-…) keys for member projects — which means a viewer can spend, see below; no management keys |
— |
Writes the role does not allow answer 403 {"error":{"type":"forbidden"}}. Everyone can
change their own password (POST /admin/api/me/password), manage their own API keys and
read /admin/api/system (the PostgreSQL and pgvector versions in it are shown to admins
only).
A role bounds the admin surface, not spend
The table above is about /admin/api and the dashboard. It is not a spending limit, and
viewer is not a read-only account — only a read-only administrator. The key routes
are deliberately role-free, because every account manages its own credentials, so a
viewer can mint itself an sk-user-… gateway key granting the projects it is a member
of, and send traffic through /v1. That spend is charged to those projects’ budgets and
attributed to the key in the usage records, exactly as an editor’s or an admin’s would
be.
What bounds that spend is never a role. It is:
- the per-project rate limits and token budgets the key runs under (below), and
- the optional per-key sub-limits set when the key is created (Key sub-limits).
A user carries no limit of its own; limits live on the project and on the key.
To cap one user, give their key a sub-limit. Note that 0 there means unlimited
(Zero means unlimited), not nothing, so
capping means setting a small ceiling — setting it to zero removes the ceiling instead
of applying one.
To stop them outright, revoke the key (POST /admin/api/keys/{id}/revoke), or
deactivate the account (PUT /admin/api/users/{id} with is_active: false).
Deactivation stops every key the user holds at once, at /v1 with
401 key_owner_inactive.
Two things that look like they would stop a key and do not:
- Dropping the user’s project membership. A gateway key carries its own grants,
validated once when the key was created;
/v1consults only those and never rechecksproject_members(API keys below). The user loses the project in the dashboard while the key keeps spending against it. - Setting a limit to
0. That is the unlimited value, as above.
Demoting the user to viewer takes away the admin surface and nothing else on the
gateway side. Role is intersected with a key’s scopes on /admin/api only, where it is
read live on every request, so a demotion narrows the sk-mgmt-… keys the user holds
immediately. It changes nothing about an sk-user-… key: /v1 does not consult roles,
and every gateway scope (chat, models, embeddings, retrieve, rerank) is one every
role including viewer already covers.
Roles do still bind where they are the control: a viewer cannot put a write, admin
or keys scope on any key it mints, and a management key needs at least editor plus
an interactive session. Recorded as ADR-003.
Project membership
Projects have a member list (member_ids). Non-admins only see projects they are a member
of; any other project id answers 404, and the global metrics endpoints
(/metrics/summary, /metrics/requests) are limited to their projects. The creator of a
project is always added as a member, and an editor cannot remove themselves through
PUT /projects/{id}/members, so editors keep access to what they manage. Admins can
replace the member set freely.
User management
PUT /admin/api/users/{id}changesroleandis_active. Deactivated users cannot sign in and their existing sessions stop working immediately.- The last active admin cannot be demoted, deactivated or deleted (
409), and nobody can deactivate or delete their own account (400). POST /admin/api/users/{id}/reset-passwordsets a new password and revokes the user’s sessions;POST /admin/api/users/{id}/sessions/revokeonly signs the user out everywhere.- Passwords must be at least 12 characters and at most 72 bytes (bcrypt’s input limit);
usernames at most 64. The same rule applies everywhere a password is set: creating a
user, resetting or changing a password, the first-run setup,
ragmux reset-passwordandADMIN_PASSWORD. Existing passwords are not re-checked; they keep working until they are changed. - Changing your own password (
POST /admin/api/me/password) signs out every other session of the account; the session that made the change stays valid. POST /admin/api/usersacceptsproject_ids; the memberships are written in the same transaction as the account, and an unknown id fails the whole request with422.GET /admin/api/usersreportsproject_countandactive_sessionsper user;GET /admin/api/mereportssession_expires_atand whether the session is a bearer token (session_bearer).
API keys
Besides the one key a project carries (sk-proj-…, rotated from the project page and
labelled the project’s default key in the dashboard), every user can mint their own
credentials in the API keys tab or through /admin/api/keys. They come in two kinds:
| Kind | Prefix | Calls | Carries |
|---|---|---|---|
gateway |
sk-user-… |
/v1 |
a set of granted projects, its own rate limits and budgets, scopes chat, models, embeddings, retrieve and rerank |
management |
sk-mgmt-… |
/admin/api |
scopes read, write, admin, keys; no projects and no limits |
All three prefixes keep the sk- head so secret scanners that watch for OpenAI-shaped
keys keep firing on a leak. The key is shown once at creation and only its first 15
characters (key_prefix) are stored for identification, next to a SHA-256 hash.
The API keys tab lists the scopes of the chosen kind as a checklist and refuses to save with none ticked, because an empty list would grant every scope. Editing a key keeps a scope the dashboard does not recognise, ticked, so saving does not silently drop it.
A key is never issued beyond its owner. Scopes only ever narrow, grants are held at creation to what the owner can reach, and the owner is looked up on every request rather than copied onto the key. What that lookup can still take away, though, depends on the surface — a grant, once given, is not rechecked.
What that lookup enforces differs by surface. On /admin/api the effective permission is
the key’s scopes intersected with the owner’s role, so demoting a user narrows every
sk-mgmt-… key they hold at once. /v1 does not read the role at all — it checks only
that the account is active — so a demotion changes nothing about an sk-user-… key:
every gateway scope is one every role including viewer already covers. See
A role bounds the admin surface, not spend.
Deactivating an account stops both kinds at once: the key stops resolving entirely
(401 key_owner_inactive at /v1).
| Kind | Scope | Allows |
|---|---|---|
| gateway | chat |
POST /v1/chat/completions |
| gateway | models |
GET /v1/models, GET /v1/models/{id} |
| gateway | embeddings |
POST /v1/embeddings |
| gateway | retrieve |
POST /v1/retrieve |
| gateway | rerank |
POST /v1/rerank |
| management | read |
every listing and metrics route (the viewer set) |
| management | write |
the routes that need editor |
| management | admin |
the routes that need admin (users, audit log, login security) |
| management | keys |
/admin/api/keys*, separate from write so a leaked write key cannot mint more keys |
All five gateway scopes are granted by default: a gateway key created with no scopes, or
with an empty list, gets chat, models, embeddings, retrieve and rerank. A scope
name outside that list is a 400 whose message lists the valid values. The check lives in
the application, not in a database constraint, so a future scope needs no migration. A
management key needs at least one scope and the editor role; the admin scope needs the
admin role.
Upgrading from v0.4. Migration 0018 gives the three new scopes to every existing
gateway key that carries chat: keys that could call chat completions can call the new
endpoints too. This is a deliberate widening of what those keys may do, and the changelog
marks it as security-relevant. Revoked and expired keys are included, so reviving or
extending one later does not change its reach. The migration only adds: it is idempotent,
it leaves updated_at alone, and it does not touch keys that carry only models or any
management key. To keep a key out of the new endpoints, edit its scopes after upgrading.
The project’s default key (sk-proj-…) carries no scopes at all, so it reaches the new
endpoints automatically, like every other /v1 route. It cannot be narrowed; mint a
user-owned gateway key with the scopes you want when you need that.
Three actions refuse an api-key principal outright and answer
403 {"error":{"code":"session_required"}}: changing your own password, resetting
someone else’s, and creating a management key. A leaked key must not be able to lock
its owner out of the account or mint a successor.
Projects a gateway key may call are the grants listed on the key, not the owner’s
project memberships. Membership is a dashboard-visibility concept; requiring both would
mean an admin editing a member list silently breaks a production key. The grants are
validated once, when the key is created or updated (you can only grant projects the
owner can access), and from then on /v1 consults only them. A key whose last grant
was deleted routes nowhere and fails closed. How a request picks one is described in the
API reference.
Expiry and revocation. expires_at is optional; POST /admin/api/keys/{id}/revoke
sets revoked_at. Both are part of the single lookup that resolves a key, so there is no
cache to invalidate and a revocation takes effect on the very next request. A key that no
longer resolves answers 401; the body carries code key_revoked, key_expired or
key_owner_inactive — an unknown key keeps the plain invalid project api key body, so
the extra detail only ever reaches someone who already holds the key bytes. The hourly
retention job deletes revoked or expired rows after 30 days, but never one that request
logs still attribute usage to; that is what keeps billing history intact.
Prefer revoke over delete: DELETE /admin/api/keys/{id} is admin-only and
answers 409 key_in_use while any request log references the key.
Key sub-limits
A gateway key carries the same four limits as a project (rate_limit_rpm,
rate_limit_tpm, budget_daily_tokens, budget_monthly_tokens, 0 = unlimited), kept
in key_usage exactly the way project_usage holds the project’s. They are a second,
independent ceiling under the project’s, not a replacement: the two tiers count against
different rows, so each is evaluated against its own counters in the order monthly →
daily → TPM (project before key) and the first violation wins. The 429 body gains
"scope", either "project" or "key"; code and type are unchanged.
The x-ratelimit-* and x-ragmux-budget-* headers report whichever tier has the smaller
remaining allowance, so a client always sees the bound it will hit first. For the
requests-per-minute limit both minute slots are reserved atomically, project first; if
the key’s slot overflows, both are released again so neither tier keeps a phantom
request.
GET /admin/api/keys/{id}/usage returns the key’s own counters in the same shape as
GET /admin/api/projects/{id}/usage, with api_key_id in place of project_id.
Case-insensitive usernames
Usernames are matched without regard to case: Admin, admin and ADMIN all sign in
to the same account, and the casing entered at creation is what is stored and shown.
Creating a user (or the first-run setup) whose name differs from an existing one only by
case answers 409 a user with that name already exists (usernames are case-insensitive).
Migration 0008 enforces this with a unique index on lower(username). A database
that already holds names differing only by case cannot be migrated automatically
because there is no safe way to pick which account keeps the name: the gateway refuses
to start with
apply migration 0008_users_ci_audit.sql: ERROR: usernames that differ only by case exist (alice, bob); rename or delete the duplicates before upgrading
Fix it by hand before starting the new version, then start it again:
-- see who collides
SELECT id, username, role, is_active, last_login_at FROM users
WHERE lower(username) IN (SELECT lower(username) FROM users GROUP BY 1 HAVING count(*) > 1)
ORDER BY lower(username), id;
-- either rename the account you want to keep apart …
UPDATE users SET username = 'alice.ops' WHERE id = 7;
-- … or delete the one that is not needed (its sessions and memberships cascade;
-- audit rows keep the username and lose the actor id)
DELETE FROM users WHERE id = 8;
Login protection
Login bodies are checked before anything else: the username (trimmed) must be 1–64
characters and the password 1–1024; anything else is a 400 that is neither recorded
nor counted.
Every remaining attempt is recorded in the database, so all replicas share the counters.
After LOGIN_USER_LIMIT_PER_MIN (default 5) failures for a username or
LOGIN_RATE_LIMIT_PER_MIN (default 10) failures from an IP within a minute, and after
LOGIN_LOCKOUT_FAILURES (default 20) failures for one username from one IP within
LOGIN_LOCKOUT_MINUTES (default 15), POST /admin/api/login answers
429 {"error":{"message":"too many login attempts, try again later","type":"rate_limited","locked":false}}
with a Retry-After header; locked is true when the lockout triggered rather than
a per-minute budget. The lockout is keyed on the username/address pair so that a
stranger hammering your username cannot lock you out from your own address; the
per-minute limits still slow them down. Setting a limit to 0 disables it. Successful
logins do not reset the counters; the windows simply expire. Attempts older than 24
hours are purged hourly.
A failed login (401) carries attempts_remaining: the per-user budget minus the
failures recorded for that username in the last minute, never below zero and absent
when LOGIN_USER_LIMIT_PER_MIN is 0. The number only depends on the recorded
attempts, so a wrong password and a username that does not exist get identical
responses and the endpoint does not reveal which accounts exist.
Admins see the state of the limiter with GET /admin/api/security/logins: failed
attempts in the last hour and 24 hours, and the username/address pairs currently locked
out with the time the lockout ends. The list is computed with the limiter’s own rule, so
it matches exactly what POST /admin/api/login refuses.
The client IP is the TCP peer address unless TRUST_PROXY_HEADERS=true, in which case
the last X-Forwarded-For entry (or X-Real-IP) is used, optionally restricted with
TRUSTED_PROXY_CIDRS; only enable it behind a reverse proxy (see
Configuration).
Audit log
Logins (login.success, login.failure, login.rate_limited, login.locked), logouts,
password changes and every create, update, delete, key rotation, upload, reprocess, member
change and connection test are written to audit_logs with the actor, target, IP and a
small JSON details object that never contains credentials. Action names are
<target>.<verb>: model.create, model.update, model.delete, model.test,
rag_store.create, rag_store.update, rag_store.delete, rag_store.reprocess_all,
document.upload, document.delete, document.reprocess, project.create,
project.update, project.delete, project.rotate_key, project.members_update,
user.create, user.update, user.delete, user.reset_password,
user.revoke_sessions, apikey.create, apikey.update, apikey.revoke,
apikey.delete, password.change, logout, setup.complete for the first
administrator created through the first-run form, and audit.exported for every
export of the log (its details hold the filters used). The apikey.* entries record
the key’s name, kind, owner, scopes, grants, limits and 15-character display prefix —
never the credential or its hash.
Most entries have a signed-in actor, but two kinds do not. Failed logins record
actor_username without an id, and ragmux reset-password (see
Configuration) records the fixed actor cli with a
null actor_user_id and ip cli; its details carry via, host, os_user,
revoked_sessions and revoked_keys. That is the one action with no account behind it,
so an emergency reset is still visible in the trail.
Admins read the log with
GET /admin/api/audit?limit=100&action=project.&actor_user_id=1&since=…&until=…&before=…
(action is a prefix match, since/until bound the time window, before pages
backwards; the response carries entries, the total for the filter and has_more)
or in the Audit dashboard tab, and download it as NDJSON with
GET /admin/api/audit/export and the same filters (at most 100 000 rows per call; use
before to slice a larger range). Entries are kept for AUDIT_RETENTION_DAYS
(default 365); export before they expire if you need a longer history.
Rate limits and budgets
Every project has four optional limits (0 = unlimited, see
Zero means unlimited), set at creation or with
PUT /admin/api/projects/{id} and shown in the dashboard’s project form:
| Field | Meaning |
|---|---|
rate_limit_rpm |
Requests per UTC minute |
rate_limit_tpm |
Prompt + completion tokens per UTC minute |
budget_daily_tokens |
Prompt + completion tokens per UTC day |
budget_monthly_tokens |
Prompt + completion tokens per UTC calendar month |
Counters live in the project_usage table, so several gateway replicas share them. All
windows are aligned to UTC boundaries (date_trunc of minute, day and month), not sliding.
The limits cover every /v1 call that spends tokens: chat completions, embeddings,
retrieve and rerank. The tokens of an embeddings call count toward the project’s limits, and
the tokens of an LLM or API rerank are added to the request that ran it. When a limit is
hit the endpoint answers
HTTP 429 Retry-After: 42
{"error":{"message":"Rate limit reached: 2 requests per minute for this project. Retry after 42 seconds.",
"type":"rate_limit_exceeded","code":"rate_limit_rpm"}}
type is rate_limit_exceeded for the per-minute limits and insufficient_quota for
budgets; code is one of rate_limit_rpm, rate_limit_tpm, budget_daily,
budget_monthly. Retry-After is the number of seconds until the violated window resets
(next minute, next UTC day or next month). Rejected requests are recorded in the request
log with status 429 (visible as rate_limited in the metrics summaries and the
dashboard) and do not consume tokens.
Response headers on every chat completion (only for limits that are set):
| Header | Value |
|---|---|
x-ratelimit-limit-requests |
configured requests per minute |
x-ratelimit-remaining-requests |
requests left in the current minute |
x-ratelimit-reset-requests |
integer seconds until the minute window resets |
x-ragmux-budget-daily-remaining |
tokens left in today’s budget |
x-ragmux-budget-monthly-remaining |
tokens left in this month’s budget |
How the checks work: limits are checked before RAG retrieval, so throttled clients do
not cost an embedding call. The requests-per-minute slot is reserved atomically before
the upstream call, so concurrent requests cannot exceed the limit; a rejected reservation
is released again so remaining stays accurate. Tokens are only known after the provider
answers, so the token limit and the budgets are checked against what has been recorded so
far. For the per-minute token limit the request’s own prompt is estimated at four
characters per token (client messages plus the project system prompt, before RAG context
is added); budgets use recorded usage only. A single request can therefore overshoot a
budget by its own size — the next request is refused. Providers that do not report
usage fall back to the same character estimate.
GET /admin/api/projects/{id}/usage returns the live counters:
{"project_id":3,"generated_at":"2026-09-18T12:00:30Z",
"minute":{"period_start":"…","resets_at":"…","requests":2,"tokens":24,"request_limit":2,"request_percent":100,"token_limit":0,"token_percent":0, …},
"day":{"…":"…","tokens":12,"token_limit":5,"token_percent":100,"resets_at":"2026-09-19T00:00:00Z"},
"month":{"…":"…"}}
Minute rows are purged after two hours, day rows after 400 days and month rows after
three years by the hourly retention job, in key_usage as well as project_usage.
A user-owned gateway key adds a second ceiling under the project’s; see Key sub-limits for how the two interact and which one the response headers describe.
Budgets are counted in tokens, not money
Ragmux 0.4 estimates what each request cost and shows it on the dashboard, but cost is
informational: no limit is enforced in money. A project cannot be given a dollar
budget, a request is never refused because a spend figure was reached, and the estimate
never feeds rate_limit_tpm, budget_daily_tokens or budget_monthly_tokens — those
stay token counters. To cap spend, cap tokens.
A rerank is priced with the connection that ran it: an API reranker (cohere_rerank,
voyage_rerank) at its own price row (Cohere per search unit, Voyage per token), and the
tokens of an LLM rerank at the chat connection that did the work. A rerank connection with
no price row adds 0 to cost_micros. The rerank tokens count toward the limits above as
before. See Rerank cost.
The estimate comes from the price table, which ships current
public list prices and is editable per model. It is not a bill: providers round,
discount and change prices without telling the gateway, and a model with no matching
price row adds a cost of zero and is logged with cost_source: "none" rather than a guess
(a rerank that ran on another connection with a price row still adds its own cost).
Metrics and retention
Every /v1 call that reaches the upstream or is turned away by a limit (chat completions,
embeddings, retrieve and rerank) is written to request_logs, one row per request, with the
status the client received plus two gateway-internal codes. The endpoint column tells the
four apart; see the request log:
| Status | Meaning |
|---|---|
2xx |
completed; tokens from the provider’s usage block, or an estimate (estimated: true) |
429 |
rejected by the project’s rate limit or budget (rate_limited in summaries) |
499 |
the client disconnected before the completion finished; the upstream call was cancelled. Counted as an error in summaries, never sent on the wire |
502 |
upstream failure: any 5xx other than 503 / 504, a transport error, or a stream that died mid-way |
503 / 504 |
the upstream answered 503 or 504 (an Anthropic 529 becomes 503), relayed with its Retry-After; 504 is also what the gateway returns when its own upstream timeout expires |
other 4xx |
relayed from the provider |
Each row also records the model name, prompt and completion tokens, latency, whether the
response was streamed and whether RAG context was injected. Prompts and completions are
not stored. Upstream error messages are stored and logged with anything that looks like
an API key (sk-…, sk-ant-…, AIza…, Bearer …) replaced by [redacted].
Since 0.4 a row also carries cached_prompt_tokens and cache_write_tokens (the parts
of prompt_tokens a provider cache served and wrote, see
Prompt caching) plus the estimated cost_micros and the
cost_source it came from. A 429 row costs nothing — nothing was sent upstream — and
a 499 row keeps the cost of whatever the provider had already reported.
GET /admin/api/metrics/summary?window=24h and GET /admin/api/projects/{id}/metrics
return the window summary (requests, errors, tokens, average and p95 latency, RAG
requests, rate_limited, estimated cost), the all-time summary, a 14-day daily series
and the 50 most recent requests; the dashboard’s overview and project pages are built on
them.
Each row also records which user-owned key made the request (api_key_id) and who owns
it (user_id); both are null for a project’s default key. The owner is denormalised so
deleting a key never destroys the history: api_key_id becomes null and user_id
stays. The CSV export does not carry the two columns yet.
A retention job (the janitor in internal/maintenance) runs one minute after start and
then hourly: request logs older than LOG_RETENTION_DAYS (default 90) and audit entries
older than AUDIT_RETENTION_DAYS (default 365) are deleted, 0 keeps a table forever.
The same pass removes login attempts older than 24 hours, expired dashboard sessions,
stale usage counters on both tiers, and api keys revoked or expired more than 30 days ago
that no request log still references.