fix(dashboard): attach Keycloak token on agent API calls #90
Reference in New Issue
Block a user
Delete Branch "fix/dashboard-bearer-token"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Dashboard shows "unable to load repositories." Agent returns
Missing authorization header401 on every protected endpoint.Root cause
The Keycloak OIDC flow (
infrastructure::auth::auth_login/auth_callback) was already wired up and storing the user'saccess_token+refresh_tokenin tower-sessions on login. But the dashboard's#[server]functions then calledreqwest::get(...)/reqwest::Client::new().post(...)against the compliance-agent without attaching the access token they already had. Agent's M7.1require_jwt_authmiddleware rejects with 401.The
Authentication requiredtext we saw on probes earlier comes from the dashboard's ownauth_middleware::require_auth— not Traefik — confirming the dashboard server is the front door and the agent is the protected backend.Fix
New
infrastructure::agent_clientmodule:Both build a
RequestBuilderfor<agent_api_url><path>, pull the session'saccess_tokenviaFullstackContext::extract, and attachAuthorization: Bearer <token>. Short-circuit (no auth header) when Keycloak isn't configured — matches the dashboard's ownrequire_authshort-circuit in the same state.Then every
#[server]function in:chat,dast,findings,graph,issues,notifications,pentest,repositories,sbom,scans,statswas migrated. 57 call sites total, all replaced.
What's deliberately left alone
infrastructure::server::webhook_proxy— forwards to the agent's separate webhook server (port 3002), authenticated via per-repo HMAC, not JWT. Bearer would do nothing useful here.infrastructure::auth::auth_callback— performs the Keycloak token exchange itself. Adding bearer auth would be circular.Test plan
cargo fmt --all -- --checkcleancargo clippy -p compliance-dashboard --features server -- -D warningscleancargo check -p compliance-dashboard --features servercleancargo check -p compliance-dashboard(web/wasm target) cleanAuthorization: Bearer <token>on outgoing API callsDeploy
Standard ORCA flow once the image rebuilds:
🤖 Generated with Claude Code
First slice of the M7.2 tenant-isolation work. Adds a `DatabasePool` that hands out per-tenant `Database` handles physically scoped to `<prefix>_<tenant_id>` Mongo databases. Isolation is at the driver, not at "we hope we filter" — a handle for tenant A literally cannot see tenant B's documents because it's connected to a different db. What's in this PR - DatabasePool::connect — pings the cluster, prepares per-tenant lazy handles. - DatabasePool::for_tenant(&TenantContext) — returns a Database scoped to that tenant. ensure_indexes runs once per tenant per process via a DashMap-backed marker; failure rolls the marker back so the next request retries. - tenant_db_name — `<prefix>_<sanitized_tenant_id>` if it fits in Mongo's 63-byte db-name cap, else `<prefix>_<sha256-16hex>` fallback. - Sanitizer rewrites the Mongo-disallowed chars (`/ \ . " $ <space> NUL`) so any future tenant_id shape works. - ComplianceAgent gains a `db_pool: DatabasePool` field next to the existing `db: Database`. Handlers / pipelines / webhooks still use `db` — they migrate to `db_pool.for_tenant(&ctx)` in M7.2-B/C and `db` goes away in M7.2-D. Test plan - cargo fmt --all clean - cargo clippy --workspace --exclude compliance-dashboard -- -D warnings clean - cargo test -p compliance-core --lib — 7 pass - cargo test -p compliance-agent --lib — 228 pass - cargo test -p compliance-agent --test tenant_isolation — 4 pass against live mongo on 27017: * pool_isolates_tenants_at_driver_level — writes for acme + globex, reads through each tenant's handle; each sees exactly its own data with no filter doc anywhere. * for_tenant_is_idempotent_index_creation — second + third call for the same tenant do not error. * tenant_db_name_sanitizes_unsafe_characters * tenant_db_name_falls_back_to_hash_when_too_long — 100-byte tenant_id collapses to a stable 8-byte hex suffix. Why per-tenant DB vs `tenant_id` field + filter - Driver-level isolation; impossible to forget the filter on one of the 184 query call-sites in compliance-agent. - Handlers don't change shape at migration — `agent.db.findings()` becomes `db.findings()` after pulling `db` from `agent.db_pool.for_tenant(&ctx)`. - GDPR delete = `db.dropDatabase()`. - On-prem deploy = the same code path, with one tenant. - Trade-off accepted: index storage duplicated per tenant; Mongo's ~thousand-db ceiling is way above the 10s-100s tenants we're targeting. Caveats - Existing `agent.db` continues to point at the single legacy db. Handlers / pipelines that use it are unscoped until M7.2-B/C migrate them. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Builds on PR M7.2-A. Every HTTP handler in compliance-agent/src/api/ now takes a TenantCtx extractor and pulls a tenant-scoped Database from agent.db_pool.for_tenant(&ctx). The query bodies are unchanged — `db.findings().find(doc! {...})` reads from the tenant's own physical database, so the filter doc cannot leak data across tenants because the wrong tenant's data is literally on a different db handle. Changes - New `dto::tenant_db(&agent, &tenant) -> Result<Database, StatusCode>` helper. Every migrated handler calls it at the top of the body instead of `let db = &agent.db;`. 500 on the rare pool failure; 4xx auth failures are already handled by the M7.1 status gate. - New `api::server::inject_dev_tenant` middleware mounted only when Keycloak is NOT configured. Synthesizes a TenantContext with tenant_id = $DEV_TENANT_ID (default `dev`) so `cargo run` against a bare Mongo + no KC still serves the API. Logged loudly as "DO NOT use in any environment with real customer data". - Test harness: TestServer mounts inject_dev_tenant so existing E2E tests reach handlers; cleanup() now drops every <db_name>_* per-tenant database, not just the legacy <db_name>. Files migrated (handler count, all pass `cargo build`): - chat.rs (3) — also rewires RagPipeline + EmbeddingStore to the tenant DB's inner() so vector search is per-tenant - dast.rs (5) - findings.rs (5) - graph.rs (7) — also rewires GraphStore inside trigger_build's spawn to the tenant DB - health.rs (1) — stats_overview migrated; public /health stays un-scoped - issues.rs (1) - notifications.rs (5) - pentest_handlers/session.rs (12) — both wizard + legacy paths, plus pause/resume/stop/get_attack_chain/get_messages/ get_session_findings/lookup_repo. PentestOrchestrator now gets the tenant DB clone in its spawn. - pentest_handlers/export.rs (1) — fans out across sessions, attack_chain_nodes, dast_findings, findings, sbom_entries, graph_nodes from a single tenant_db acquisition - pentest_handlers/stats.rs (1) - pentest_handlers/stream.rs (1) — SSE handler verifies session via the tenant DB before subscribing - repos.rs (6) - sbom.rs (5) - scans.rs (1) help_chat.rs has no DB queries and was skipped. Test plan - cargo fmt --all clean - cargo clippy --workspace --exclude compliance-dashboard -- -D warnings clean - cargo test -p compliance-core --lib — 7 pass - cargo test -p compliance-agent --lib — 228 pass - cargo test -p compliance-agent --test tenant_isolation — 5 pass (driver-level isolation still holds post-handler migration) - cargo test -p compliance-agent --test tenant_status_middleware — 6 pass What's not yet migrated (PR-C / PR-D) - scheduler.rs (6 sites), pipeline/orchestrator.rs (14), pentest/orchestrator.rs (13), webhooks (gitea/github/gitlab), trackers/jira.rs, pipeline/dedup.rs etc. — background paths without a JWT-derived tenant context. - agent.db is still in the ComplianceAgent struct as a transitional handle for those paths. PR-D removes it once PR-C migrates the background paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Closes the loop on M7.2 isolation for paths that don't have a JWT context: scheduler, webhooks, and the agent's `run_scan` / `run_pr_review` helpers all now take a `tenant_id` at the boundary and resolve to a tenant-scoped `Database` via `db_pool.for_tenant_id(...)`. Internal orchestrators (PipelineOrchestrator, PentestOrchestrator) and pipeline helpers were already DB-agnostic — they take `db: Database` at construction and don't care which tenant it points to. Changes - DatabasePool::for_tenant_id(&str) — same as for_tenant but accepts a bare tenant_id. Background paths don't have a full TenantContext. for_tenant is now a thin wrapper that delegates. - agent.run_scan(tenant_id, repo_id, trigger) — pulls the tenant database before constructing the PipelineOrchestrator. Was: run_scan(repo_id, trigger) reading agent.db. - agent.run_pr_review(tenant_id, repo_id, ...) — same shape. - Webhook routes change: /webhook/{tenant_id}/{platform}/{repo_id}. Tenant is part of the URL path because webhooks arrive without a JWT — they're authenticated via per-repo HMAC, not the tenant gate. The dashboard surfaces the full per-tenant URL when the repo is registered. All three handlers (gitea, github, gitlab) updated. - scheduler.rs — iterates tenants from $SCHEDULER_TENANT_IDS (comma-separated env), or DEV_TENANT_ID's `dev` default. Both scan_all_repos and monitor_cves now run once per configured tenant. M7.2-D will replace this static config with a pull from the tenant-registry. - api/handlers/repos.rs::trigger_scan now passes tenant.0.tenant_id. What's unchanged because it didn't need to change - PipelineOrchestrator, PentestOrchestrator: take `db: Database` at construction — they're tenant-DB-agnostic by design. The caller picks the tenant DB. - pipeline/{dedup,graph_build,issue_creation,sbom/mod}.rs, pentest/{context,report/html/*}.rs, trackers/jira.rs, llm/triage.rs: take `&Database` or `&mongodb::Database` as args, transitively tenant-scoped via the caller. Test plan - cargo fmt --all clean - cargo clippy --workspace --exclude compliance-dashboard -- -D warnings clean - cargo test -p compliance-core --lib — 7 pass - cargo test -p compliance-agent --lib — 228 pass - cargo test -p compliance-agent --test tenant_isolation — 5 pass - cargo test -p compliance-agent --test tenant_status_middleware — 6 pass What's left (PR-D) - Drop the transitional agent.db field — no remaining call sites (verified by `grep -rn "agent\.db\b" compliance-agent/src`). - main.rs / TestServer stop building the legacy Database; only the pool remains. - Add cross-tenant admin helpers (list tenants, drop tenant DB) on the pool for offboarding flows. - Pull tenants from the tenant-registry instead of an env var. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Symptom: "unable to load repositories" — agent returns "Missing authorization header" 401 on every protected endpoint because the dashboard's server functions were calling reqwest::get without an Authorization header. The Keycloak OIDC flow (auth_login / auth_callback) was already wired up and storing the access_token in tower-sessions, but the access_token was never threaded into outbound calls. Fix - New `infrastructure::agent_client` module exposes: - `agent_request(method, path) -> RequestBuilder` - `agent_get(path) -> RequestBuilder` (sugar for GET) Both pull the session's access_token (via FullstackContext extract) and attach `Authorization: Bearer <token>`. When Keycloak is not configured the helper short-circuits — matching the dashboard's require_auth middleware which short-circuits in the same state. - Migrated every #[server] function in: - chat, dast, findings, graph, issues, notifications, pentest, repositories, sbom, scans, stats - 57 call sites total, all replaced. - Left as-is: - `infrastructure::server::webhook_proxy` — forwards to the agent's separate webhook server (port 3002), which is HMAC-authenticated, not JWT-authenticated. - `infrastructure::auth::auth_callback` — performs the KC token exchange itself; bearer auth would be circular. Test plan - cargo fmt --all clean - cargo clippy -p compliance-dashboard --features server -- -D warnings clean - cargo check -p compliance-dashboard --features server clean - cargo check -p compliance-dashboard (web target) implicit via build - Manual: after deploy, dashboard's repositories page loads without 401; calls now carry Authorization: Bearer header to the agent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>