# 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. Source: https://ragmux.com/docs/users-and-limits/ Updated: 2026-10-11 Project: https://github.com/ragmux/ragmux (AGPL-3.0-or-later) --- 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](/docs/api/). ## 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](#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](/docs/configuration/#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; `/v1` consults only those and never rechecks `project_members` ([API keys](#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}` changes `role` and `is_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-password` sets a new password and revokes the user's sessions; `POST /admin/api/users/{id}/sessions/revoke` only 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-password` and `ADMIN_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/users` accepts `project_ids`; the memberships are written in the same transaction as the account, and an unknown id fails the whole request with `422`. - `GET /admin/api/users` reports `project_count` and `active_sessions` per user; `GET /admin/api/me` reports `session_expires_at` and 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](#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](/docs/api/#choosing-a-project). **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: ```sql -- 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](/docs/configuration/#behind-a-reverse-proxy)). ## 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 `.`: `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](/docs/configuration/#reset-password)) 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](/docs/configuration/#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 ```json 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: ```json {"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](#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](/docs/rag/#reranking). The estimate comes from the [price table](/docs/api/#model-prices), 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](/docs/observability/#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](/docs/providers/#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.