Multi-User Setup¶
This document covers how to configure and operate the Prometheus platform for
multiple simultaneous users. It is the operator/developer companion to the
prometheus_api implementation reference at
prometheus_api/docs/agent/MULTI_USER.md.
For a security-architecture view of every secret and key mentioned below —
what each one protects, where it's set, and what breaks if it's lost or
rotated — see docs/docs/dev-docs/encryption-and-keys.md.
How multi-user works at the platform level¶
The frontend is a React SPA (frontend/) talking to a standalone FastAPI
backend/ service (app/routers/*, app/services/*) — the successor to the
old Chainlit Python frontend. Login, session state, and A2A calls all live in
backend/; the identity plane sits on top of that:
User logs in (local password, or an admin-configured OIDC provider -- see
"OIDC identity providers" below)
↓
backend/app/security.py issues a session-cookie JWT (BACKEND_JWT_SECRET),
carrying { sub: user_id, role, sid, exp } — this is NOT the identity token below
↓
Every POST /api/chat/messages (backend/app/routers/chat.py):
identity.mint_for_user(claims.sub) mints a short-lived PLATFORM JWT
(signed RS256 with USER_IDENTITY_PRIVATE_KEY) via
prometheus_api.agent.identity.mint_user_token
↓
backend/app/a2a_client.py::A2AClient.send() attaches it to
message.metadata["userToken"] on the outbound A2A call
↓
client-agent verifies the JWT → establishes identity_scope for the turn
├─ Binds conversation_id to user_id on first touch (conv_owner Redis key)
├─ Rejects cross-user access to existing conversations
└─ Propagates userToken to every downstream specialist agent call
↓
Specialist agents verify the JWT independently
└─ Can call self.get_credential(resource) for per-user JIT secrets
Two distinct JWTs, two distinct keys — easy to conflate, so be precise:
- BACKEND_JWT_SECRET (HS256) signs the
browser session cookie — backend-to-browser only, never leaves the backend.
- The platform identity JWT — backend-to-agents, carried in
message.metadata["userToken"] — is signed RS256 with whichever key
identity.mint_for_user() resolves (see "Managing signing keys" below):
admin-db's active key if one exists, else USER_IDENTITY_PRIVATE_KEY. Every
agent verifies with USER_IDENTITY_PUBLIC_KEY or a JWKS_URL, never the
private key — a compromised agent cannot forge tokens for other users.
Managing signing keys via the admin UI (preferred)¶
On a fresh install you do not create the first key. At startup, each backend
worker calls ensure_active_signing_key() (backend/app/signing_keys.py)
from the lifespan in backend/app/main.py. When admin-db has no active
SigningKey row, it generates one RS256 key, stores it, and activates it.
A Postgres advisory lock serializes the workers, so one install gets one key.
It creates nothing when USER_IDENTITY_PRIVATE_KEY is set, and it leaves
inactive and retired rows untouched.
An admin can generate, activate, and retire RS256 keys from Admin > Signing
Keys (frontend/src/components/admin/AdminSigningKeys.tsx) — no .env edit
or container restart needed. Once a key is active, Postgres is
authoritative: backend/app/identity.py::mint_for_user() prefers admin-db's
active SigningKey row (private key Fernet-encrypted at rest,
backend/app/crypto.py) over the env var below, which is a static-key
alternative for a deployment that sets it before the first start.
Encrypting that private key (and any OIDC client secret, see below) at rest
needs ADMIN_DB_ENCRYPTION_KEY set — make up/make restart generate one
into .env automatically if it's blank (scripts/init_env.py, via the
init-env Makefile target), so this is normally nothing to think about.
Starting the stack any other way (e.g. docker compose up directly, or CI)
needs python3 scripts/init_env.py run first, or the key set by hand.
Leaving it unset doesn't block local-password login — only signing-key
generation and OIDC provider configuration need it. At startup, the backend
logs a warning that names ADMIN_DB_ENCRYPTION_KEY, creates no key, and
boots. The admin API calls fail with an actionable error message (not a
bare 500).
POST /api/admin/signing-keysgenerates a keypair without activating it.POST /api/admin/signing-keys/{id}/activatemakes it the one key that mints new tokens; whichever key was active before is deactivated but stays in the table, un-retired, until explicitly retired.POST /api/admin/signing-keys/{id}/retiremarks a key done (no longer eligible for/activate) — but it keeps appearing in the JWKS below, so tokens minted before a rotation stay verifiable until they naturally expire. Refuses to retire the active key with no replacement (would silently fall back to the env var, usually unconfigured).GET /.well-known/jwks.json(unauthenticated, backend root — not under/api) serves every key in the table, active or retired, as an RFC 7517 keyset. Point every agent'sJWKS_URLhere instead of distributingUSER_IDENTITY_PUBLIC_KEYby hand — including third-party agents, which never need to see this backend's private key at all. All behindrequire_adminexcept the JWKS endpoint itself, which is public by design (it's public key material only).
The platform mints its own internal identity JWT rather than forwarding the session cookie or any external IdP token. This means multi-user works correctly even when deployed without a federated IdP (e.g. local password users only).
Configuration¶
Generate a keypair (static-key alternative only)¶
You normally do not need this step: the backend creates the first key at
startup (see above). Use this env-var path only for a deployment that must
sign with a key that you generate and hold outside admin-db. Set
USER_IDENTITY_PRIVATE_KEY before the first start, because the backend then
creates no DB key. Signing is RS256
only — there is no shared-secret option. Generate a keypair with the tool that
ships in prometheus_api (already installed in every agent container,
including the backend):
prometheus-identity-keygen --out-dir ./keys
# → ./keys/identity_private.pem (0600 — backend only, never on an agent)
# → ./keys/identity_public.pem (safe to distribute to every agent)
Or with plain openssl (zero dependencies):
openssl genrsa -out identity_private.pem 4096
openssl rsa -in identity_private.pem -pubout -out identity_public.pem
See prometheus_api/docs/agent/IDENTITY_KEY_MANAGEMENT.md for key rotation and
third-party (JWKS) distribution.
Required env vars (add to .env)¶
# Backend only — mints tokens. Never set this on an agent container.
USER_IDENTITY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# Every agent — verifies tokens. Safe to distribute; cannot forge anything.
USER_IDENTITY_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
# Shared across backend + all agents; must match exactly.
USER_IDENTITY_ISS=prometheus-platform # default
USER_IDENTITY_AUD=prometheus-agents # default
USER_IDENTITY_TTL=7200 # seconds; backend only (minted-token lifetime)
USER_IDENTITY_REQUIRED=true # false for dev/smoke-test only
Multi-line PEM values need careful quoting in .env/compose; the
\n-escaped single-line form above (which prometheus-identity-keygen
prints by default) is the simplest to paste directly.
.env.example / docker-compose.yml (already wired up)¶
USER_IDENTITY_PRIVATE_KEY and USER_IDENTITY_TTL are set only on the
backend service's own environment: block. JWKS_URL, USER_IDENTITY_PUBLIC_KEY,
USER_IDENTITY_ISS, USER_IDENTITY_AUD, USER_IDENTITY_REQUIRED, and
CREDENTIAL_BROKER live in the root x-common-env anchor, shared by backend
and every root-defined service (client-agent). Every per-agent
docker-compose.yml under agents/*/ (no shared anchor across separate
compose files) carries the same verification-side vars individually.
JWKS_URL defaults to http://backend:8000/.well-known/jwks.json — the
backend's own container name/port on agent-network, reachable from every
agent without any configuration. This is what makes an admin-generated
signing key (Admin > Signing Keys) verifiable by every agent immediately,
with no USER_IDENTITY_PUBLIC_KEY set anywhere: JWKS_URL, when set, always
takes priority over a static public key (prometheus_api.agent.identity.verify_user_token).
Only override it for an agent that can't reach that internal URL — a
third-party agent outside this docker network needs this platform's public
JWKS URL instead (or a static public key), from the platform operator.
agents/debug-agents/* (only included with DEBUG_AGENTS=true) predate this
wiring and don't verify identity tokens at all yet — a known gap, not
something this section's defaults reach.
Local users¶
Local accounts are no longer configured in config.yml — they live in a
dedicated Postgres database (admin-db, separate from litellm-db; see
docker-compose.yml's admin-db service and ADMIN_DATABASE_URL /
ADMIN_DB_ENCRYPTION_KEY in .env.example). Passwords are hashed with bcrypt
(backend/app/passwords.py), never stored or compared in cleartext.
On first run (no users row exists yet), the React app shows a setup wizard
(frontend/src/components/SetupWizard.tsx) instead of the login screen — it
creates the first account, always role: "admin", and logs it in immediately.
Backing endpoints: GET /api/setup/status reports whether setup is still
needed (frontend/src/App.tsx's boot sequence checks this before deciding
whether to render the wizard, login, or the app), POST /api/setup/bootstrap
creates that first account (and refuses, 409, once any account exists).
backend/app/security.py::PasswordIdentityProvider then authenticates against
that table on every login.
Creating additional local users, editing roles, deactivating, or deleting
accounts is now an admin-only API: GET/POST /api/admin/users and
PATCH/DELETE /api/admin/users/{id} (backend/app/routers/admin_users.py),
gated by app.security.require_admin (403 for non-admins). It refuses any
change that would leave zero active admins (demoting, deactivating, or
deleting the last one all 409). The React app surfaces this as an Admin
tab (frontend/src/layouts/ProjectView.tsx), visible only when the logged-in
user's role is "admin" (tracked in frontend/src/lib/store/session.ts) —
frontend/src/components/admin/AdminUsers.tsx is the users table + create/edit
forms.
OIDC identity providers (Okta or any generic OIDC issuer)¶
Configured entirely through Admin > Identity Providers
(frontend/src/components/admin/AdminOidcProviders.tsx) — no .env vars, no
restart. Backend CRUD: GET/POST /api/admin/oidc-providers,
PATCH/DELETE /api/admin/oidc-providers/{id} (backend/app/routers/admin_oidc.py),
same require_admin gate as the users/keys admin APIs. client_secret is
write-only — accepted on create/update, Fernet-encrypted at rest
(app/crypto.py), never returned by any response. provider_id (a URL slug,
e.g. okta) is immutable after creation — it's the path segment in the login
routes below and the value stored in users.provider for accounts created
through it.
The full authorization-code flow (app/security.py::GenericOidcProvider,
app/oidc.py for the discovery/token-exchange/verification plumbing):
GET /api/auth/login/{provider_id}— redirects the browser to the provider'sauthorization_endpoint(fetched fresh from{issuer_url}/.well-known/openid-configurationon every login — an admin editingissuer_urltakes effect immediately, nothing to restart). Sets a short-lived, signedoidc_flowcookie carrying this one attempt'sstate/nonce— there is no server-side flow-state store, so any backend worker can finish the callback without shared state. (Sessions, logout markers and the SSE bus live in Valkey; seebackend/README.md.)- Register this exact URL as the redirect URI with the provider:
${BACKEND_PUBLIC_URL}/api/auth/callback/{provider_id}.BACKEND_PUBLIC_URL(.env) must be set to whatever origin the browser actually uses to reach the platform (e.g.http://localhost:5150for the Vite dev server in dev, or the public HTTPS domain in production) — left blank, the backend falls back to introspecting the request it received (app/security.py::_callback_url), which is wrong the moment anything proxies/apirequests to it: it sees its own internal host:port, not the browser-facing origin, and the identity provider rejects the login with a redirect_uri mismatch. GET /api/auth/callback/{provider_id}— verifiesstateagainst the flow cookie, exchanges the code for tokens, verifies theid_token's signature against the issuer's own JWKS (jwt.PyJWKClient, so key rotation on the IdP's side is handled automatically), plusiss/aud/exp/nonce, then upserts ausersrow keyed by(provider_id, external_subject)— auto-provisioned with the provider's configureddefault_roleon first login, matched by that same key (not username) on every login after. Existing local accounts and other providers' accounts are never touched.GET /api/auth/providers(public, unauthenticated) lists enabled providers byprovider_id/display_name—Login.tsxrenders a "Sign in with {display_name}" button per entry, replacing what used to be a permanently-hidden Okta-shaped stub.
Deactivating an OIDC-provisioned account (Admin > Users, same as a local
account) blocks that identity's next login (400) without touching the
provider config or other accounts under it. Disabling the provider itself
(Admin > Identity Providers) blocks all logins through it, including
existing accounts, until re-enabled — both routes read live from the DB on
every request, never a cached snapshot.
Whichever identity source signs a user in, mint_for_user(claims.sub) (the
platform identity JWT described above) is unaffected — it mints from whatever
sub the login flow resolved, local password or OIDC alike.
Credential broker¶
Development (default)¶
No extra config needed. Set CREDENTIAL_BROKER=env (or omit it — it is the
default) and configure the global credentials as before:
CREDENTIAL_BROKER=env
DEEPLYNX_JWT_TOKEN=...
DEEPLYNX_ORG_ID=42
DEEPLYNX_PROJECT_ID=7
SLURM_JWT_TOKEN=...
SLURM_REST_URL=http://slurm.hpc/slurm/v0.0.40
Production with Vault¶
CREDENTIAL_BROKER=vault
VAULT_ADDR=https://vault.example.com
VAULT_JWT_ROLE=prometheus-agent
# One of the following workload auth methods:
VAULT_AGENT_SECRET=<per-agent-shared-secret>
# or mTLS (stronger):
VAULT_MTLS_CERT=/certs/agent.crt
VAULT_MTLS_KEY=/certs/agent.key
VAULT_MTLS_CA=/certs/ca.crt
Each user must link their resource credentials once (via a forthcoming consent UI or directly in Vault) at the paths the policy covers:
secret/data/deeplynx/{user_id} → {"jwt_token": "user-specific-dl-token"}
secret/data/slurm/{user_id} → {"token": "user-slurm-token", "user": "hpcuser"}
See prometheus_api/docs/agent/MULTI_USER.md § Vault broker for the full Vault
policy HCL.
Third-party agent providers¶
If an external team or vendor registers an agent with your platform, give them
only USER_IDENTITY_PUBLIC_KEY (or point them at JWKS_URL) — they can verify
that incoming JWTs were signed by your platform without being able to forge
tokens for any user. This is the whole point of RS256: never share
USER_IDENTITY_PRIVATE_KEY with anyone outside the platform backend.
Every PrometheusAgent now serves GET /.well-known/jwks.json — the RSA public
key as a JSON Web Key Set. Third-party agents set JWKS_URL to this endpoint
and verify_user_token fetches and caches the key automatically, with transparent
key rotation via the JWT kid header. No private key or shared secret needs to
leave your organisation.
See prometheus_api/docs/agent/IDENTITY_KEY_MANAGEMENT.md for the full key
distribution table, key generation steps, and rotation guide.
Mesh/IFC dataset isolation (decision: no per-user path namespacing)¶
GET /api/artifacts/mesh/{dataset_id}/{filename} (backend/app/routers/artifacts.py)
requires a valid session (Depends(current_claims)) — a real improvement over
the old Chainlit setup, which served /public/mesh-data/{dataset_id}/...
unauthenticated. But the endpoint is deliberately not per-user
path-namespaced: dataset_id is a content hash and the converted-VTP cache is
shared across users by design (uploading the same file twice converts once).
This is intentional, not an oversight. The actual data-access boundary is
DeepLynx itself: each user/project has separate DeepLynx credentials
(see Credential broker below), so a user can never reach another user's
source artifact regardless of how the local rendering cache is laid out —
namespacing the local VTP cache by user would only protect a cache of content
the requester could already fetch on their own. GET /api/artifacts/{id}/content
(the non-mesh artifact endpoint) is separately — and more strongly — isolated:
it resolves against session.artifacts, so it can only ever address the
calling session's own catalog.
If this changes (e.g. mesh conversion moves to per-user DeepLynx-backed storage rather than a local disk cache), revisit this section before assuming the auth gate alone is sufficient.
Conversation isolation¶
Each conversation is owned by the user who created it:
- First turn:
record_conversation_owner(conv_id, user_id)writes a Redis keyconv_owner:{conv_id} = user_idand addsconv_idtouser:convs:{user_id}. - Subsequent turns:
assert_conversation_owner(conv_id, user_id)rejects the request if a different user owns it. /history: scoped to the current user via theuser:convs:{user_id}Redis set — no global scan.
The underlying Redis keys for artifacts, task graphs, and status are not
renamed; access control is enforced at the entry point only. This is correct
because conversation_id is a random UUID that is unguessable in practice.
User-scoped uploads¶
POST /api/uploads (backend/app/routers/uploads.py) mints a user_token via
identity.mint_for_user(claims.sub) and passes it to
A2AClient.stage_upload(..., user_token=...), which forwards it as an explicit
query param on the client-agent's raw /uploads/stage endpoint
(agents/client-agent/orchestrator/service.py::_stage_upload_endpoint).
That endpoint verifies the token directly (verify_user_token(user_token))
rather than reading the ambient identity context — it's a raw Starlette route
added via app.add_route, not routed through PrometheusAgentExecutor.execute(),
so no identity_scope() is ever active there. (An earlier version of this code
tried to read current_user_id() from that ambient context and always got
None — a real bug, not a hypothetical; if you see staged uploads with an
empty user_id, check that user_token is actually being passed as a query
param, not assumed from context.)
The verified user_id is stamped into the staged upload's metadata. When the
agent later processes the upload, it rejects retrieval by a different user:
# file_upload_handler.resolve_staged_upload(uploaded_file, user_id="alice")
# → raises PermissionError if the upload was staged by "bob"
Developer guide: adding credentials to a new agent¶
-
Set
requires_user_isolation = Trueon the agent if it holds credentials across multipleawaitcalls (e.g. job submission + polling). Stateless agents that fetch and immediately use a credential do not need it. -
Call
self.get_credential(resource)inside_execute:
from prometheus_api.agent import PrometheusAgent
from prometheus_api.storage_client.connection import ConnectionConfig
class MyNewAgent(PrometheusAgent):
requires_user_isolation = True # optional; set if holding creds across awaits
async def _execute(self, params, context, event_queue):
user_id = params["user_id"] # already verified
# JIT credential — never cache this
cred = await self.get_credential("deeplynx")
config = ConnectionConfig.from_credential(cred)
await self.send_status_update(context, event_queue,
message=f"Fetching data for {user_id}...")
# ... use config ...
return {"status": "success"}
- Add the agent's resource requirements to its
agent_card.json(future: will feed the consent/permission UI):
{
"name": "my-new-agent",
"required_credentials": [
{"resource": "deeplynx", "required": true}
]
}
- Run
make generateto pick up the newdocker-compose.ymlif you created a new agent directory.
Running without multi-user (single-user / dev)¶
Set USER_IDENTITY_REQUIRED=false in .env. Agents synthesise a synthetic
UserIdentity(user_id="local") for requests that carry no userToken. All
existing behaviour is preserved. The ownership and history-scoping features
still work (scoped to the "local" user_id).
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
Agent returns "Identity verification failed: userToken has expired" |
Turn lasted longer than USER_IDENTITY_TTL |
Increase USER_IDENTITY_TTL (default 7200) |
Agent returns "No 'userToken' found in message metadata" |
USER_IDENTITY_REQUIRED=true but the backend has no active signing key and no USER_IDENTITY_PRIVATE_KEY (usually the startup warning about ADMIN_DB_ENCRYPTION_KEY) |
Set ADMIN_DB_ENCRYPTION_KEY (python3 scripts/init_env.py) and restart the backend, or activate a key under Admin > Signing Keys |
| Agent fails to verify with "USER_IDENTITY_PUBLIC_KEY ... is required" | Agent has no public key or JWKS_URL configured |
Set USER_IDENTITY_PUBLIC_KEY (never the private key) on the agent |
CredentialError: VAULT_ADDR is required |
CREDENTIAL_BROKER=vault but Vault env not set |
Set VAULT_ADDR and VAULT_JWT_ROLE, or switch to CREDENTIAL_BROKER=env |
/history shows no conversations |
User has not yet created any conversations in this deployment | Expected — history is now per-user |
| Worker pool capacity error | Too many concurrent users for USER_WORKER_MAX |
Increase USER_WORKER_MAX (default 32) |
See also¶
prometheus_api/docs/agent/MULTI_USER.md— implementation reference for agent authorsprometheus_api/docs/agent/IDENTITY_KEY_MANAGEMENT.md— RS256 key generation, key distribution, third-party agent integration via JWKSprometheus_api/docs/agent/GUIDE.md— general PrometheusAgent developer guidedocs/docs/dev-docs/adding-agents.md— how to create a new specialist agentdocs/docs/dev-docs/artifacts.md— artifact model and rendering