Ragmux
Documentation / Guides

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; /v1 consults only those and never rechecks project_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} 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.

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.

Arrow keys to move, Enter to open.