Multi-User Authentication & Access Control
The dashboard supports multiple named users with two roles, admin and user, each signing in with a per-account Telegram one-time code (OTP). This replaced an earlier single-operator design where the dashboard had exactly one hard-coded session; see Deployment for the deploy-time sequencing and Infrastructure for the PostgreSQL container this all runs against.
Role capability matrix
| Capability | admin | user |
|---|---|---|
| Sign in via Telegram OTP | Yes | Yes |
List / create dashboard users (/settings/users) | Yes | No — 403 |
| Production trading-bot surfaces (Dashboard, Cockpit, Analytics, AI Market, Events, Battle, IV, Journal, Backtest, Research, Policies, Settings, Trust Center) | Yes | No — Forbidden panel in the UI, 403 from the API |
| Own portfolio (holdings, transactions, goals, watchlist, connections, AI research, reports, imports) | Own data only | Own data only |
| Another user’s private financial records | No — admin status never bypasses ownership | No — 404, not 403, so existence is never revealed |
Role editing, account deletion, impersonation, and public self-registration do not exist in this phase — an admin can only create a new user-role account (see Creating a user). There is no endpoint or UI path that promotes an existing account to admin.
User-owned vs. shared vs. admin-only data
Every table below carries an authoritative owner_user_id column and is scoped at the ORM chokepoint (a SQLAlchemy do_orm_execute/before_flush listener pair in arb_bot/portfolio_app/db/engine.py) plus PostgreSQL row-level security as defense-in-depth (arb_bot/auth/tenancy.py:apply_rls). No tenant context set (an anonymous or mis-scoped call) sees zero rows and cannot write — the isolation is fail-closed, never fail-open.
| Category | Examples | Access rule |
|---|---|---|
| User-owned (private) | Holdings, transactions, portfolios, goals, watchlist, SIP plans, liabilities, income events, import batches, reconciliation runs, AI research runs/dossiers, broker connections & credentials, watchlist-intelligence recommendations/digests | Scoped to the authenticated caller only. Admin status grants no extra visibility here. |
| Shared read-only reference data | Instrument/InstrumentAlias mapping, price & FX observations, corporate-action events, Zerodha instrument master | No owner column — global, contains no private account data. |
| Admin-only global operational data | Live trades, metrics, kill switch, go-live state, broker health, research/backtest tables, live policies, evidence, options council, settings, logs, Cliq command queue | Belongs to the production trading bot, not to any one dashboard user. Gated by require_admin, never exposed to a user-role account. |
Login flow
- User enters their username (not an email, not a Telegram handle) and requests a code.
POST /api/auth/request-otp {"username"}always returns the same generic response — whether the account exists, is inactive, or is being throttled is never revealed. If the account is real and active, a 6-digit code is sent to that user’s own configured Telegram chat.- User enters the code;
POST /api/auth/verify-otp {"username", "otp"}validates it against a hashed, single-use, attempt-bounded challenge and, on success, creates a new persisted session row and sets anHttpOnlysession cookie. - The frontend loads
GET /api/auth/meto learn the signed-in identity (id, username, display name, role) and routes anadminto/dashboard, an ordinaryuserto/portfolio. POST /api/auth/logoutrevokes only the current session — other devices/sessions for the same or other users are unaffected.
OTPs are 6 digits, generated with secrets, expire after Config.OTP_EXPIRY_MINUTES (5 minutes), are stored only as an HMAC-SHA256 hash, allow at most 5 verification attempts, and are single-use. Requesting more than 3 codes for the same account within 5 minutes is silently throttled (still returns the generic success response). Multiple users can be signed in concurrently; a new login never revokes another user’s session.
Creating a user (admin only)
From Settings → User management (/settings/users, hidden from and rejected for non-admins), an admin fills in a username, display name, and the new user’s Telegram chat ID, then submits. The account is always created with role user — the API rejects any client-supplied role field outright (422). Duplicate usernames (case-insensitive) and duplicate chat IDs are both rejected with a 409 naming which field conflicted.
@userinfobot, and it replies with their numeric chat ID — paste that into the “Telegram chat ID” field. The user signs in with a one-time code sent to that chat; there is no password.Bootstrap & the initial admin
On a database with an empty dashboard_users table, arb_bot/auth/bootstrap.py:run_bootstrap() creates exactly one admin account from existing operator configuration — no manual SQL, no interactive prompt:
| Variable | Default | Meaning |
|---|---|---|
DASHBOARD_BOOTSTRAP_ADMIN_USERNAME | admin | Username for the initial account |
DASHBOARD_BOOTSTRAP_ADMIN_DISPLAY_NAME | Administrator | Display name for the initial account |
TELEGRAM_CHAT_ID | (existing) | Delivery destination for the initial admin’s OTPs — the production operator’s existing chat, so upgrading a live deployment requires no new Telegram setup |
run_bootstrap() is called both from db/migrate.py (production deploys) and from the dashboard’s own FastAPI startup hook (fresh local installs), and is safe to call on every restart:
- Empty users table +
TELEGRAM_CHAT_IDset → creates the one admin, then backfills every pre-existing user-owned row (holdings, transactions, broker connections, everything in the ownership table above) to that admin’sowner_user_id. - Empty users table + no
TELEGRAM_CHAT_ID→ logs a warning and does nothing; safe to re-run once the variable is set. - Any user already exists → a strict no-op for account creation. Restarting the process, redeploying, or re-running migrations never creates a second admin and never promotes an existing account, even if
DASHBOARD_BOOTSTRAP_ADMIN_*still points at that user’s username.
Migration & rollout sequence
The auth/tenancy schema ships as ordinary numbered files in db/, applied in order by db/migrate.py:
| Migration | Adds |
|---|---|
056_dashboard_users_auth.sql | dashboard_users, dashboard_otp_challenges, dashboard_sessions |
057_tenant_ownership.sql | current_app_user_id() helper + nullable owner_user_id column + index on every user-owned table |
058/059 | Rescopes two pre-existing global UNIQUE constraints (watchlist symbol, portfolio name) to per-owner, so two different users can hold the same symbol/name |
063_price_observations_owner.sql | Adds nullable owner_user_id to portfolio.price_observations with a partial index. The table mixes shared market data (provider != 'manual', owner_user_id IS NULL, globally visible) with user-entered manual valuations (provider = 'manual', stamped with the creating principal on insert, filtered by owner_user_id on read). See user-owned vs. shared data. |
Deploying to an existing (pre-multi-user) database:
make docker-deploy(orpython db/migrate.pydirectly) applies 056–059, thenrun_bootstrap()runs automatically at the end of the script.- Verify the bootstrap admin exists and every pre-existing row is owned:
SELECT count(*) FROM dashboard_users;(expect1on a first-ever rollout) and, for each user-owned table,SELECT count(*) FROM <table> WHERE owner_user_id IS NULL;(expect0— the migration sets the columnNOT NULLonce the backfill completes, so a non-zero count here means the backfill did not finish and the deploy should not proceed to the app restart). - Restart the
dashboardservice and confirm the existing operator can log in with their existing username and receive an OTP on their existing Telegram chat. - Re-running
db/migrate.pyor restarting the dashboard again is a no-op — every step above is idempotent.
owner_user_id or row-level security and will not run correctly against the migrated schema (RLS in particular will deny the old code’s unauthenticated write paths). Restoring the DB without reverting the code, or reverting the code without restoring the DB, both leave the deployment broken.Troubleshooting
| Symptom | Check |
|---|---|
| User never receives their OTP | Confirm the chat ID stored for that account is correct (an admin can see it in the Users list, the ordinary current-user endpoint never exposes it); confirm TELEGRAM_BOT_TOKEN is valid and TELEGRAM_ENABLED=true; check for the 3-requests-per-5-minutes throttle (the generic success response is returned even when throttled, so a user who mashed “resend” may simply need to wait). |
| Admin creation fails with 409 | The response names the conflicting field — “Username already exists” (usernames are compared case-insensitively) or “Telegram chat ID already exists” (one Telegram account can only back one dashboard user). |
| Newly created user can’t reach any bot-control page | Expected — new accounts are always role user. Bot control surfaces are admin-only by design; see the role matrix above. |
| Ordinary user’s request for another user’s object returns 404 | Expected and intentional — cross-tenant access never reveals whether the object exists (never a 403 that would confirm it does). |
MCP & Google connection
Every authenticated dashboard user can link the Keycloak identity used by an MCP client from Settings → Connections (/account/connections). Google is an upstream login broker inside Keycloak only: the Agent Gateway continues to accept only Keycloak-issued RS256 access tokens. The durable identity key is the canonical Keycloak (issuer, subject) pair; Google email and provider values are display/audit metadata and never participate in authorization.
The flow is default-off behind MCP_GOOGLE_LINKING_ENABLED. When disabled, the API returns 404 and the Connections route renders a feature-disabled card. When enabled, the dashboard startup validator rejects missing, weak, insecure, or cross-origin configuration before serving traffic.
Self-service link flow
- The user selects Connect Google account. The SPA requests a six-digit Telegram OTP for the signed-in user and verifies it. The resulting HttpOnly step-up grant is purpose-bound, session-bound, HMAC-signed, single-use, attempt-capped, and short-lived (default grant TTL: 7 minutes; OTP TTL: 5 minutes).
- After successful verification the SPA immediately posts to
POST /api/auth/mcp-link/start. In one transaction the service consumes that grant and creates a user/session-scoped challenge containing only digests of state and nonce plus a Fernet-encrypted PKCE verifier. The response containsauthorization_url,tx_id, andexpires_at; raw state is present only inside the server-built authorization URL. - The browser completes Authorization Code + PKCE S256 through Keycloak and its Google broker. The callback verifies the state digest, decrypts the PKCE verifier only for the token exchange, and validates the ID token with an exact RS256 algorithm and non-empty
kid, trusted JWKS,iss,aud, multi-audienceazp, expiry, future-iatskew, nonce, non-empty subject, andbroker=google. The broker claim is supplied by adashboard-mcp-linkclient protocol mapper that copies Keycloak’sidentity_providerclient-session note. The OAuth token exchange itself binds the exact registered callback URI. - The callback performs a compare-and-set transition from
CREATEDtoGOOGLE_AUTHENTICATED, then redirects only to the fixed relative route/account/connections?tx=<id>. The page pollsGET /api/auth/mcp-link/pendingand receives only masked email metadata. - Selecting Confirm link requires a second, fresh Telegram step-up. Confirm locks the link challenge, grant, existing subject mapping, and the user’s active issuer mapping inside one database transaction before creating or reactivating the mapping. Database uniqueness enforces one owner for each
(issuer, subject)and at most one active mapping per(user_id, issuer). - Disconnect also requires a fresh Telegram step-up. It atomically consumes the grant, writes the audit event, and sets
is_active=false. The next MCP request is rejected even if its Keycloak access token has not expired.
Link challenges expire after DASHBOARD_MCP_LINK_LINK_TTL_MINUTES (default 10 minutes). Callback delivery from a different session returns 403 link_session_mismatch. A pending lookup from the wrong session is deliberately indistinguishable from an unknown transaction and returns 404 link_transaction_not_found. Expired and consumed transactions return their stable 410 error codes.
Tenant isolation and administration
- All self-service repository operations include an explicit
user_idand, where applicable, session filter. These four identity-linking tables do not yet use PostgreSQL RLS; adding RLS is a defense-in-depth follow-up, not a property claimed by this release. - Ordinary status, pending, confirm, cancel, and disconnect requests cannot discover or mutate another user’s rows. Conflict responses never identify the other owner.
- The admin OAuth-mapping API is an explicit break-glass inventory and recovery surface. It can list mapping metadata and deliberately remove or recreate a mapping; merely holding the admin role never bypasses the self-service ownership checks or grants access to another user’s portfolio data.
- The audit table is append-only at the database layer: migration 071 rejects UPDATE and DELETE. Email hints are masked before any response reaches the SPA and are never used by the resolver.
Troubleshooting
| Symptom | Check |
|---|---|
| Connections shows feature disabled / API returns 404 | Set MCP_GOOGLE_LINKING_ENABLED=true only after all runtime validation requirements in Deployment are present. |
| No Telegram step-up code arrives | Verify the signed-in user’s Telegram chat id, bot token, Telegram enablement, and request throttle. A provider delivery failure invalidates the newly created OTP. |
step_up_expired | The OTP defaults to 5 minutes and the verified grant to 7 minutes. Request and verify a fresh code for the exact action. |
provider_unavailable | Check Keycloak health and the issuer-origin token/JWKS endpoints, then re-run the idempotent bootstrap if realm resources drifted. |
oidc_validation_failed | Verify the exact callback registration, PKCE S256 client policy, Google IdP configuration, and the client-session-note broker protocol mapper. Detailed token contents and secrets are intentionally absent from logs and responses. |
identity_already_linked | The identity belongs to another mapping. Use the explicit admin OAuth-mapping recovery API to verify ownership and deliberately remove/reassign it; ordinary disconnect cannot act on another user’s row. |
link_already_exists | The current user already has an active mapping for this issuer. Disconnect it with a fresh step-up before linking another account. |
| Pending lookup returns 404 in another browser | Expected: pending lookup is user- and session-scoped. Complete or restart the transaction in the browser session that initiated it. |
| Settings is connected but the MCP client is unauthorized | Configure the client with the MCP URL, not a Google token or a manually supplied bearer. The client discovers Keycloak OAuth from MCP metadata and the gateway resolves its Keycloak subject through the active mapping. |
| Secret rotation | Rotate the Google secret, update the deployment secret store, and re-run the bootstrap. For the dashboard client secret, update the store, re-run the bootstrap, and restart the dashboard so both sides use the same value. |
Per-user LLM provider chain
The per-user LLM provider chain configured under /portfolio/ai-providers applies to every LLM-bearing surface in the product: AI assistant chat (/api/ai/ask, /api/ai/refresh), AI Market predictions, AI Research analytics insight, and AI Research V3 runs (the chain is snapshotted onto the run row at creation time so the worker reproduces it later).
For the trading bot and Telegram, the chain resolves by the active Telegram chat id. When a dashboard user is bound to that chat id (set when the user is created), the bot uses that user’s chain; otherwise it falls back to the system Codex chain.