raxx-console — Operator Admin Console Architecture
Status: Draft Owner: software-architect Last updated: 2026-04-22 Related ADRs: 0001, 0002, 0003, 0004
1. Goal + North Star
console.raxx.app is the internal operator surface for the Raxx platform. It gives the operations team visibility and control over user accounts, feature flags, paper-trade audit, infrastructure health, and maintenance mode — without giving operators direct database access or raw Heroku access.
North star: any admin action that affects money, user access, or data must produce an audit row attributable to the named admin, not the system. Impersonation, flag flips, account disables — all attributed, all timestamped, all queryable.
2. Non-Goals (v1)
The following are deliberately out of scope and must not slip in:
- Billing, subscription management, or refund processing
- Alpaca brokerage account provisioning or management
- Bulk user import or CSV-based account creation
- GDPR deletion self-service (erasure requests are user-initiated via Raptor's
/api/gdpr/erase) - Customer-facing support chat or ticketing integration
- Any mobile or native-app variant of the console
3. System Context
graph TD
subgraph "Public internet"
AdminBrowser["Admin browser\n(passkey + TOTP)"]
end
subgraph "console.raxx.app (Heroku: raxx-console)"
ConsoleApp["raxx-console\nFlask + Jinja2 + HTMX"]
ConsoleDB[("Console Postgres\n(admin identities,\nsessions, audit_log)")]
end
subgraph "api.raxx.app (Heroku: raxx-api-prod)"
Raptor["Raptor\n(Flask backend_v2)"]
RaptorDB[("Raptor Postgres\n(user data,\ntrade history)")]
end
subgraph "Cloudflare"
CFPages["Antlers\n(raxx.app)"]
end
subgraph "External"
HerokuAPI["Heroku Platform API"]
CFApi["Cloudflare API"]
Sentry["Sentry"]
Mail["Mail provider"]
end
AdminBrowser -->|HTTPS, session cookie| ConsoleApp
ConsoleApp -->|reads/writes| ConsoleDB
ConsoleApp -->|short-lived JWT, HTTPS| Raptor
Raptor -->|reads/writes| RaptorDB
ConsoleApp -->|GET deploy status| HerokuAPI
ConsoleApp -->|GET Pages deploy| CFApi
ConsoleApp -->|GET error counts| Sentry
ConsoleApp -->|send bootstrap / invite emails| Mail
Raptor -->|honor MAINTENANCE_MODE flag| CFPages
4. Invariants
All platform-level invariants apply. Console-specific notes:
- No stored credentials, ever. Console stores passkey public keys and TOTP seeds. TOTP seeds are written once at enrollment, stored encrypted at rest (AES-256-GCM, key in
CONSOLE_TOTP_ENCRYPTION_KEYenv var). The seed is never returned via any API response after enrollment. This is the one exception to "no secrets stored": TOTP seeds must persist across process restarts, and server-side TOTP is an accepted pattern in combination with passkey as the primary factor. The seed is not a replayable user-secret the way a password is; it cannot impersonate without also possessing the enrolled passkey. - Passkey is primary factor. TOTP is mandatory second factor. Both must be presented at login. This is stricter than the user app (passkey-only there). Rationale: operator access has higher stakes.
- No direct DB peering with Raptor. Console calls Raptor admin endpoints over HTTPS. The Raptor database URL is never present in console's env.
- Every state-changing admin action writes to
console.audit_log. This is not optional and is enforced at the middleware layer, not per-route. - Impersonation is always audit-attributed to the admin. Impersonation actions in Raptor carry the
X-Console-Admin-Idheader; Raptor logs this. The user being impersonated never appears as the actor. - Paper-first gating is Raptor's responsibility. Console does not gate live trading; it can read the flag state but not bypass it.
5. Auth + Session Flow
5.1 Role Matrix
| Permission | superadmin | ops | support | readonly |
|---|---|---|---|---|
| View dashboard (infra health) | Y | Y | Y | Y |
| View user list (minimal PII) | Y | Y | Y | Y |
| View user audit log | Y | Y | Y | Y |
| Disable / re-enable user account | Y | Y | N | N |
| Trigger manual recovery email | Y | Y | Y | N |
| Impersonate user (audited) | Y | N | N | N |
| View paper-trade audit browser | Y | Y | Y | Y |
| Toggle feature flags | Y | Y | N | N |
| Toggle maintenance-mode banner | Y | Y | N | N |
| Invite new admin (any role) | Y | N | N | N |
| Elevate or demote admin role | Y | N | N | N |
| View console audit log | Y | Y | N | N |
Roles are stored as an enum in console_admins.role. No bare boolean is_admin column exists anywhere.
5.2 First-Admin Bootstrap
Bootstrap is a one-shot CLI operation that must be run from within the Heroku dyno context:
heroku run --app raxx-console python -m console.bootstrap --email <addr>
Sequence:
sequenceDiagram
participant CLI as bootstrap CLI
participant DB as Console Postgres
participant Mail as Mail provider
participant Admin as First admin (browser)
CLI->>CLI: Verify no admins exist in DB (abort if any)
CLI->>DB: Insert admin stub (email, role=superadmin, status=pending)
CLI->>CLI: Generate signed 24h one-shot token (HMAC-SHA256, CONSOLE_BOOTSTRAP_SECRET)
CLI->>CLI: Print token URL to stdout
CLI->>Mail: Send token URL to ADMIN_RECOVERY_EMAIL
Admin->>ConsoleApp: GET /bootstrap/claim?token=<tok>
ConsoleApp->>ConsoleApp: Verify token (sig + expiry + not consumed)
ConsoleApp->>Admin: Render passkey registration + TOTP seed QR
Admin->>ConsoleApp: POST /bootstrap/complete {attestation, totp_code}
ConsoleApp->>ConsoleApp: Verify passkey attestation + first TOTP code
ConsoleApp->>DB: Store webauthn_credential, encrypted TOTP seed, mark token consumed
ConsoleApp->>Admin: Redirect to /dashboard
Token is consumed on first successful /bootstrap/complete. A second call with the same token returns 410 Gone. The CONSOLE_BOOTSTRAP_SECRET is an env var; rotating it invalidates any outstanding bootstrap tokens (acceptable — you would just re-run the CLI).
5.3 Post-Bootstrap Admin Invite
Once at least one admin exists, new admins are added via invite:
superadminsubmits invite form: target email + role.- Console sends a 48h signed invite token to the target email (same HMAC pattern,
purpose=admin_invite). - Target clicks link, registers passkey + TOTP.
- On completion, an existing admin (any superadmin) receives an email: "New admin X has registered. Confirm their access." — contains an approve/reject link.
- Approval sets
console_admins.status = active. Rejection soft-deletes the pending record.
This contingent-authorization step means no admin is active without a second human confirming it.
5.4 Normal Login
sequenceDiagram
participant Admin as Admin browser
participant Console as raxx-console
Admin->>Console: GET /login
Console-->>Admin: Render passkey prompt
Admin->>Console: POST /auth/passkey/begin
Console-->>Admin: WebAuthn challenge
Admin->>Console: POST /auth/passkey/complete {assertion}
Console->>Console: Verify assertion, check admin status=active
Console-->>Admin: Render TOTP prompt (no session yet)
Admin->>Console: POST /auth/totp/verify {code}
Console->>Console: Verify TOTP (±1 window), issue session cookie
Console-->>Admin: Redirect to /dashboard
Session is issued only after both factors succeed. Session cookie is HttpOnly, Secure, SameSite=Strict, 8-hour fixed window (no rolling — admin sessions are shorter than user sessions). Every subsequent request re-checks console_admins.status to handle mid-session revocations.
6. Data Model (Console Postgres)
Console has its own Postgres database. It stores only admin identity data, sessions, and the console-side audit log. It never replicates Raptor user data.
console_admins
id TEXT PK (uuid v4)
email TEXT UNIQUE NOT NULL
role TEXT NOT NULL -- 'superadmin'|'ops'|'support'|'readonly'
status TEXT NOT NULL -- 'pending'|'active'|'suspended'
invited_by TEXT NULL FK -> console_admins.id
created_at TIMESTAMP NOT NULL
activated_at TIMESTAMP NULL
suspended_at TIMESTAMP NULL
CHECK (role IN ('superadmin','ops','support','readonly'))
CHECK (status IN ('pending','active','suspended'))
console_webauthn_credentials
id TEXT PK (credential_id, base64url)
admin_id TEXT FK -> console_admins.id ON DELETE CASCADE
public_key BLOB NOT NULL
sign_count INTEGER NOT NULL DEFAULT 0
transports TEXT NULL
aaguid TEXT NULL
device_label TEXT NULL
created_at TIMESTAMP NOT NULL
last_used_at TIMESTAMP NULL
console_totp_seeds
admin_id TEXT PK FK -> console_admins.id ON DELETE CASCADE
encrypted_seed BLOB NOT NULL -- AES-256-GCM, key=CONSOLE_TOTP_ENCRYPTION_KEY
enrolled_at TIMESTAMP NOT NULL
last_verified_at TIMESTAMP NULL
console_sessions
id TEXT PK -- SHA-256(random 256-bit token)
admin_id TEXT FK -> console_admins.id ON DELETE CASCADE
issued_at TIMESTAMP NOT NULL
expires_at TIMESTAMP NOT NULL -- issued_at + 8h, no rolling
revoked_at TIMESTAMP NULL
ip_prefix TEXT NULL -- /24 IPv4, /48 IPv6
user_agent TEXT NULL
console_bootstrap_tokens
id TEXT PK
email TEXT NOT NULL
token_hash TEXT NOT NULL -- SHA-256 of raw token
purpose TEXT NOT NULL -- 'bootstrap'|'admin_invite'
expires_at TIMESTAMP NOT NULL
consumed_at TIMESTAMP NULL
created_at TIMESTAMP NOT NULL
console_audit_log
id BIGSERIAL PK
actor_admin_id TEXT NOT NULL FK -> console_admins.id
action TEXT NOT NULL -- 'user.disable', 'flag.toggle', 'impersonate.begin', ...
target_kind TEXT NULL -- 'raptor_user'|'feature_flag'|'admin'|'maintenance_mode'
target_id TEXT NULL
context JSONB NULL -- redacted; no PII in context values
raptor_user_id TEXT NULL -- denormalized for impersonation rows; NOT a FK to Raptor
at TIMESTAMP NOT NULL
-- retention: 2 years
What is deliberately absent: any column named password, password_hash, recovery_code. The CI grep from ADR-0002 extends to cover the console/ directory tree.
7. Data Access Pattern — Console to Raptor
Console calls Raptor admin endpoints via authenticated HTTPS. Authentication uses a short-lived (15-minute) JWT signed with CONSOLE_RAPTOR_JWT_SECRET (shared env var, rotatable). The JWT carries {"sub": "<admin_id>", "role": "<role>", "iat": ..., "exp": ...}. Raptor validates this JWT on every request to /api/admin/* routes.
Console never reads Raptor's database URL. If Raptor is unreachable, console degrades gracefully (shows cached last-known status where available, shows "Raptor unreachable" badge elsewhere).
Required Raptor admin endpoints (Raptor must implement these in separate sub-cards):
| Method | Path | Console usage | Role gate (console-side) |
|---|---|---|---|
| GET | /api/admin/users |
User list page, paginated | ops, support, readonly |
| GET | /api/admin/users/{id} |
User detail | ops, support, readonly |
| GET | /api/admin/users/{id}/audit |
User audit log | ops, support, readonly |
| POST | /api/admin/users/{id}/disable |
Disable account | ops |
| POST | /api/admin/users/{id}/enable |
Re-enable account | ops |
| POST | /api/admin/users/{id}/recovery-email |
Kick recovery email | ops, support |
| POST | /api/admin/users/{id}/impersonate |
Begin impersonation session | superadmin |
| DELETE | /api/admin/users/{id}/impersonate |
End impersonation session | superadmin |
| GET | /api/admin/paper-trades |
Paper trade audit browser | ops, support, readonly |
| GET | /api/admin/feature-flags |
Read flag state | ops, readonly |
| PUT | /api/admin/feature-flags/{name} |
Write flag state | ops |
| GET | /api/system/status |
Dashboard health | all |
| POST | /api/admin/maintenance |
Set maintenance mode | ops |
Raptor must log every inbound admin call to its own audit_log with the X-Console-Admin-Id header value captured as the actor. This creates a double audit trail: console logs the outbound action, Raptor logs the inbound execution.
8. Deployment Topology
console.raxx.app
→ Cloudflare DNS (CNAME -> raxx-console.<heroku-app-domain>)
→ Heroku app: raxx-console
buildpack: heroku/python
Procfile: web: gunicorn --chdir console --bind 0.0.0.0:$PORT --workers 1 --timeout 60 app:app
formation: 1x eco dyno (upgrade to basic at launch)
addons: heroku-postgresql:essential-0
Environment variables (Heroku config vars):
CONSOLE_ENV = production
DATABASE_URL (injected by Heroku Postgres addon)
CONSOLE_BOOTSTRAP_SECRET (HMAC key for bootstrap + invite tokens; rotatable)
CONSOLE_TOTP_ENCRYPTION_KEY (AES-256-GCM key for TOTP seeds; rotatable without data loss if re-encrypted on rotation)
CONSOLE_RAPTOR_JWT_SECRET (shared with raxx-api-prod; rotatable)
CONSOLE_SESSION_SECRET (Flask session signing key)
ADMIN_RECOVERY_EMAIL (bootstrap failsafe email destination)
MAIL_PROVIDER_KEY (separate sender domain from user app — see §13)
SENTRY_DSN_CONSOLE (separate project from raxx-api; Python/Flask platform)
WEBAUTHN_RP_ID = console.raxx.app # NOT raxx.app — see ADR 0012
WEBAUTHN_ORIGIN = https://console.raxx.app
CONSOLE_RAPTOR_JWT_SECRET rotation: update config var on both raxx-console and raxx-api-prod simultaneously. In-flight JWTs (15 min max) will fail until they expire; acceptable for an operator console.
CONSOLE_TOTP_ENCRYPTION_KEY rotation: requires a one-time re-encryption migration (decrypt all seeds with old key, re-encrypt with new key) run as a Heroku one-off dyno before the new key is set.
Staging mirrors prod: raxx-console-staging, console-staging.raxx.app (or internal access only). Staging shares no data with prod.
9. Observability
- Sentry project:
raxx-console(separate project fromraxx-api), platform: Python / Flask (matches the console stack per ADR 0004). DSN stored asSENTRY_DSN_CONSOLEin Heroku config vars + GitHub secrets. Separate DSN so console errors do not inflate API error counts and vice versa. Confirmed on 2026-04-22. - Structured logs: gunicorn access logs → Heroku log drain → existing log aggregator. Logs redact email addresses and IP full octets at the log-drain level.
- Console audit log (
console_audit_logtable) is the source of truth for admin actions. A read of the audit log itself writes a row (action: audit_log.read,actor: <admin_id>). - Health endpoint:
GET /healthreturns{"status": "ok", "db": "ok"|"error"}. No auth required; used by Heroku health checks and the deploy pipeline smoke test. - Dashboard metrics are pulled at page load (not pushed/streamed): Heroku Platform API for dyno status, Cloudflare API for Pages deploy status, Raptor
/api/system/status, Sentry API for 24h error count. All four calls run concurrently with a 5s timeout each; partial failure shows "unavailable" badges, not a 500.
10. Failure Modes
| Failure | Behavior |
|---|---|
| Raptor is down | Console shows "Raptor unreachable" banner. User management and flag pages show last-known cached state (TTL 5 min) or error state. Console itself remains available. |
| Console Postgres is down | Console returns 503 for all pages. Health endpoint returns {"db": "error"}. |
| Admin loses passkey | Admin emails another superadmin to trigger /admin-recovery. A superadmin can email a new bootstrap token to the locked-out admin's registered email, scoped to purpose=passkey_reset. This re-runs the passkey registration flow, revokes all prior passkeys for that admin, and writes audit_log.action = admin.passkey_reset. If no other superadmin exists (single-admin situation), fall back to heroku run python -m console.bootstrap --reset-email <addr> which requires Heroku access (i.e. you already have infra access). |
| Admin loses TOTP device | Same recovery path as passkey loss — contingent authorization from another superadmin. TOTP seed is re-enrolled alongside passkey in the same recovery flow. |
CONSOLE_TOTP_ENCRYPTION_KEY lost |
TOTP seeds are unreadable. All admins must re-enroll TOTP. This is operationally disruptive but not a security incident (seeds were encrypted, not exposed). Trigger: re-enrollment flow on next login. |
| Bootstrap token expires unused | Re-run heroku run python -m console.bootstrap --email <addr>. Prior pending admin stub is cleaned up first. |
| Maintenance-mode flag stuck on | Any ops or superadmin can toggle it off. If console is unreachable, the flag can be cleared via heroku config:set MAINTENANCE_MODE=false --app raxx-api-prod as a last resort (the env var is the kill-switch; Raptor checks it on every request). |
11. Migrations
Console database is standalone. Migrations live in console/db/migrations/ using the same Alembic-or-raw-SQL pattern established in backend_v2/db/.
Migration 0001: creates all tables in §6 above.
Rollback: console/db/migrations/0001_down.sql drops all console tables. There is no Raptor data in console — rollback is clean.
Schema changes to Raptor admin endpoints are independent. Console degrades gracefully on unexpected Raptor response shapes (strict response parsing with fallback to error display, never a crash).
12. Rollout Plan
- Bootstrap phase: Scaffold app, deploy to
raxx-console-staging, runheroku run python -m console.bootstrap --email kris@moosequest.neton staging. First admin registered. No feature flags needed — the app itself is gated by not existing yet. - Internal alpha: All v1 features enabled, access restricted to registered admins only (the subdomain is not publicly linked). Exercise each feature against staging Raptor.
- Raptor admin endpoint phase: Implement Raptor's
/api/admin/*endpoints (separate sub-cards) and wire them to console. - Prod launch: DNS cutover for
console.raxx.app, deployraxx-consoleprod app, bootstrap first prod admin. - Post-launch: Invite remaining ops/support admins via the in-app invite flow.
13. Security Considerations
- No Alpaca credentials in console. Console never touches Alpaca. It does not have
ALPACA_*env vars. - PII in console: Admin email addresses (operators are staff, not end users; GDPR still applies to staff data). Retention: lifetime of employment + 1 year after account suspension. Erasure: manual (
heroku run python -m console.admin delete --id <id>). - TOTP seeds at rest: AES-256-GCM, key in env, never logged, never returned in API responses. The key is the only persistent secret in the system.
- Audit log retention: 2 years (matches Raptor, DPA requirement).
- Breach path: If
console_totp_seedsis exfiltrated, seeds are encrypted. Decryption requiresCONSOLE_TOTP_ENCRYPTION_KEY. Incident response: rotate key (triggers re-enrollment), revoke all active sessions, notify per ADR-0003 breach pipeline. - Session fixation: Session ID is regenerated on each successful two-factor completion.
- Rate limits:
/auth/passkey/completeand/auth/totp/verifyare rate-limited per IP: 10 attempts / 10 minutes, exponential backoff. Lockout after 20 failures in 1 hour writesaudit_log.action = auth.lockoutand emails the locked-out admin. - Secrets location: All secrets are Heroku config vars. None appear in files that ship. The CI grep from ADR-0002 covers
console/as well. - Kill-switch:
CONSOLE_AUTH_DISABLED=1shuts down all/auth/*routes to 503 and logs every attempt, for use during an active incident.
14. Decisions locked (2026-04-22)
All open questions resolved by user on 2026-04-22. Recorded for traceability.
- Mail provider: separate sender domain. Console mail sends from
no-reply@console.raxx.app(or similar under the console subdomain) — NOT the user-app sender. Rationale: if the console sender domain gets flooded / abused / blacklisted, we can null-route it without affecting user-facingraxx.appemail deliverability. Requires separate SPF / DKIM / DMARC records onconsole.raxx.app.MAIL_PROVIDER_KEYmay or may not be the same Google Workspace / SendGrid account; what matters is the sender domain is isolated. - Heroku Platform API credentials: v1 manual rotation. Kris owns the tokens, rotates them quarterly, console displays their last-rotation date as a dashboard signal. The full automated rotation service (containerized, cron-scheduled, console-invokable, with pre/post smoke tests and rollback) is a v2 feature with its own epic (filed separately). Not in console v1 scope.
- Cloudflare API token scope: v1 manual rotation, read-only for dashboard. Same pattern as Heroku — read-only token minimally scoped for the Pages deploy status widget. Rotation handled manually for v1; automated in the v2 rotation service.
- WEBAUTHN_RP_ID for console: separate —
console.raxx.app. NOT shared withraxx.app. Per ADR 0012. Browser-level origin matching prevents user credentials from being offered at console login and vice versa. See ADR 0012 for rationale (hardware-key role binding, defense in depth, zero pre-launch migration cost). - Sentry project: separate. Platform: Python / Flask (matches console stack per ADR 0004). DSN stored as
SENTRY_DSN_CONSOLEin Heroku config + GitHub secrets. Billing: confirmed acceptable under the current tier.
14a. Deferred to v2 / own epic
- Automated secret-rotation service (Heroku API, Cloudflare API, SendGrid, Sentry, future OAuth tokens). Containerized, cron-scheduled, console-invokable, telemetry on success/failure, auto-rollback on post-rotation smoke-test failure. Separate epic filed; depends on console v1 shipping first.
End of doc. ADR 0004 records the stack choice. ADR 0012 records the separate-RP-ID decision for console WebAuthn. Sub-cards listed in the PR description.