Compare commits

..

3 Commits

Author SHA1 Message Date
Sharang Parnerkar cdfbb62f9d feat(m7.2-B): migrate API handlers to per-tenant database pool
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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>
2026-06-17 13:28:33 +02:00
Sharang Parnerkar 003835764e fixup(m7.2-A): validate db_prefix at connect, bump hash to 16 bytes
CI / Check (pull_request) Successful in 8m29s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Addresses review feedback on the hash-fallback path.

The original `debug_assert!(hashed.len() <= MAX_DB_NAME_LEN)` was a
runtime hack that vanished in release builds. With an 8-byte hash
truncation (~2^32 birthday-collision resistance), two tenant_ids
hashing to the same suffix would silently share a database — no
panic, no rollback, just cross-tenant data leak. Not acceptable for
a regulated-industry product.

Changes:
- Bump hash truncation 8 → 16 bytes (32 hex chars). 2^64 birthday
  resistance — collision-impossible at our scale.
- Add MAX_PREFIX_LEN (= 30) and validate db_prefix.len() at
  `DatabasePool::connect`. The runtime hash-fallback arithmetic is
  now provably within Mongo's 63-byte cap; drop the debug_assert!.
- New test `connect_rejects_overlong_db_prefix` exercises the
  inclusive bound (30 passes, 31 fails).
- Existing hash-fallback test now asserts a 32-char hex suffix +
  basic distinctness for two different inputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 13:16:46 +02:00
Sharang Parnerkar e3aabe7d18 feat(m7.2-A): introduce per-tenant DatabasePool
CI / Check (pull_request) Successful in 8m40s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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>
2026-06-17 11:58:24 +02:00
24 changed files with 592 additions and 858 deletions
+23 -14
View File
@@ -6,7 +6,7 @@ use tokio::sync::{broadcast, watch, Semaphore};
use compliance_core::models::pentest::PentestEvent; use compliance_core::models::pentest::PentestEvent;
use compliance_core::AgentConfig; use compliance_core::AgentConfig;
use crate::database::DatabasePool; use crate::database::{Database, DatabasePool};
use crate::llm::LlmClient; use crate::llm::LlmClient;
use crate::pipeline::orchestrator::PipelineOrchestrator; use crate::pipeline::orchestrator::PipelineOrchestrator;
@@ -16,9 +16,12 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 5;
#[derive(Clone)] #[derive(Clone)]
pub struct ComplianceAgent { pub struct ComplianceAgent {
pub config: AgentConfig, pub config: AgentConfig,
/// Per-tenant Mongo broker. Every code path must obtain a /// Transitional single-database handle. Used by handlers that have
/// tenant-scoped [`crate::database::Database`] from this pool — /// not yet been migrated to `db_pool.for_tenant(&ctx)` (M7.2-B/C).
/// there is no single shared database any more. /// Will be removed once every call site is tenant-scoped (M7.2-D).
pub db: Database,
/// Per-tenant Mongo broker introduced in M7.2-A. Handlers should
/// prefer this and obtain a tenant-scoped [`Database`] from it.
pub db_pool: DatabasePool, pub db_pool: DatabasePool,
pub llm: Arc<LlmClient>, pub llm: Arc<LlmClient>,
pub http: reqwest::Client, pub http: reqwest::Client,
@@ -31,7 +34,7 @@ pub struct ComplianceAgent {
} }
impl ComplianceAgent { impl ComplianceAgent {
pub fn new(config: AgentConfig, db_pool: DatabasePool) -> Self { pub fn new(config: AgentConfig, db: Database, db_pool: DatabasePool) -> Self {
let llm = Arc::new(LlmClient::new( let llm = Arc::new(LlmClient::new(
config.litellm_url.clone(), config.litellm_url.clone(),
config.litellm_api_key.clone(), config.litellm_api_key.clone(),
@@ -45,6 +48,7 @@ impl ComplianceAgent {
.unwrap_or_default(); .unwrap_or_default();
Self { Self {
config, config,
db,
db_pool, db_pool,
llm, llm,
http, http,
@@ -56,27 +60,28 @@ impl ComplianceAgent {
pub async fn run_scan( pub async fn run_scan(
&self, &self,
tenant_id: &str,
repo_id: &str, repo_id: &str,
trigger: compliance_core::models::ScanTrigger, trigger: compliance_core::models::ScanTrigger,
) -> Result<(), crate::error::AgentError> { ) -> Result<(), crate::error::AgentError> {
let db = self.db_pool.for_tenant_id(tenant_id).await?; let orchestrator = PipelineOrchestrator::new(
let orchestrator = self.config.clone(),
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone()); self.db.clone(),
self.llm.clone(),
self.http.clone(),
);
orchestrator.run(repo_id, trigger).await orchestrator.run(repo_id, trigger).await
} }
/// Run a PR review: scan the diff and post review comments. /// Run a PR review: scan the diff and post review comments.
pub async fn run_pr_review( pub async fn run_pr_review(
&self, &self,
tenant_id: &str,
repo_id: &str, repo_id: &str,
pr_number: u64, pr_number: u64,
base_sha: &str, base_sha: &str,
head_sha: &str, head_sha: &str,
) -> Result<(), crate::error::AgentError> { ) -> Result<(), crate::error::AgentError> {
let db = self.db_pool.for_tenant_id(tenant_id).await?; let repo = self
let repo = db .db
.repositories() .repositories()
.find_one(mongodb::bson::doc! { .find_one(mongodb::bson::doc! {
"_id": mongodb::bson::oid::ObjectId::parse_str(repo_id) "_id": mongodb::bson::oid::ObjectId::parse_str(repo_id)
@@ -87,8 +92,12 @@ impl ComplianceAgent {
crate::error::AgentError::Other(format!("Repository {repo_id} not found")) crate::error::AgentError::Other(format!("Repository {repo_id} not found"))
})?; })?;
let orchestrator = let orchestrator = PipelineOrchestrator::new(
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone()); self.config.clone(),
self.db.clone(),
self.llm.clone(),
self.http.clone(),
);
orchestrator orchestrator
.run_pr_review(&repo, repo_id, pr_number, base_sha, head_sha) .run_pr_review(&repo, repo_id, pr_number, base_sha, head_sha)
.await .await
+1 -6
View File
@@ -158,16 +158,11 @@ pub async fn get_ssh_public_key(
#[tracing::instrument(skip_all, fields(repo_id = %id))] #[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn trigger_scan( pub async fn trigger_scan(
Extension(agent): AgentExt, Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, StatusCode> {
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
let tenant_id = tenant.0.tenant_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = agent_clone if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await {
.run_scan(&tenant_id, &id, ScanTrigger::Manual)
.await
{
tracing::error!("Manual scan failed for {id}: {e}"); tracing::error!("Manual scan failed for {id}: {e}");
} }
}); });
+4 -50
View File
@@ -78,28 +78,19 @@ impl DatabasePool {
/// first call per tenant (per process). Cheap on the hot path — /// first call per tenant (per process). Cheap on the hot path —
/// subsequent calls skip the round-trip. /// subsequent calls skip the round-trip.
pub async fn for_tenant(&self, ctx: &TenantContext) -> Result<Database, AgentError> { pub async fn for_tenant(&self, ctx: &TenantContext) -> Result<Database, AgentError> {
self.for_tenant_id(&ctx.tenant_id).await let db_name = self.tenant_db_name(&ctx.tenant_id);
}
/// Like [`Self::for_tenant`] but accepts a bare tenant_id.
/// For background paths (scheduler, webhooks, pipeline orchestrators)
/// that don't have a full [`TenantContext`] but know which tenant
/// they're operating on (typically resolved from a URL path, a job
/// argument, or the registry).
pub async fn for_tenant_id(&self, tenant_id: &str) -> Result<Database, AgentError> {
let db_name = self.tenant_db_name(tenant_id);
let db = Database::from_database(self.client.database(&db_name)); let db = Database::from_database(self.client.database(&db_name));
// `DashMap::insert` returns the previous value; `None` means we // `DashMap::insert` returns the previous value; `None` means we
// were the first writer for this tenant_id and own the // were the first writer for this tenant_id and own the
// index-ensure work. // index-ensure work.
if self.ensured.insert(tenant_id.to_string(), ()).is_none() { if self.ensured.insert(ctx.tenant_id.clone(), ()).is_none() {
if let Err(e) = db.ensure_indexes().await { if let Err(e) = db.ensure_indexes().await {
// Roll the marker back so the next request retries. // Roll the marker back so the next request retries.
self.ensured.remove(tenant_id); self.ensured.remove(&ctx.tenant_id);
return Err(e); return Err(e);
} }
tracing::debug!( tracing::debug!(
tenant_id = %tenant_id, tenant_id = %ctx.tenant_id,
db_name = %db_name, db_name = %db_name,
"Indexes ensured for tenant database" "Indexes ensured for tenant database"
); );
@@ -140,43 +131,6 @@ impl DatabasePool {
pub fn client(&self) -> &Client { pub fn client(&self) -> &Client {
&self.client &self.client
} }
/// List every Mongo database currently belonging to this pool,
/// identified by the `<db_prefix>_` prefix. The result is the raw
/// database names — opening one for offboarding/cleanup goes
/// through [`Self::client`].
///
/// Note: hashed-fallback names (very long tenant_ids) lose the
/// original tenant_id at the cluster level — we know a database
/// exists for *some* tenant but not which one. In practice
/// tenant_ids are UUIDs (36 chars) and never hit the fallback,
/// so this is a theoretical concern, not an operational one.
pub async fn list_tenant_db_names(&self) -> Result<Vec<String>, AgentError> {
let prefix = format!("{}_", self.db_prefix);
let names = self.client.list_database_names().await?;
Ok(names
.into_iter()
.filter(|n| n.starts_with(&prefix))
.collect())
}
/// Drop the database for a specific tenant. Used by GDPR delete
/// and tenant offboarding. Idempotent — dropping a non-existent
/// database is a no-op at the driver level.
///
/// Also evicts the tenant from the in-memory `ensured` set so a
/// later re-provision triggers fresh `ensure_indexes`.
pub async fn drop_tenant(&self, tenant_id: &str) -> Result<(), AgentError> {
let db_name = self.tenant_db_name(tenant_id);
self.client.database(&db_name).drop().await?;
self.ensured.remove(tenant_id);
tracing::info!(
tenant_id = %tenant_id,
db_name = %db_name,
"Dropped tenant database"
);
Ok(())
}
} }
/// Mongo database names disallow `/`, `\`, `.`, `"`, `$`, ` `, and NUL. /// Mongo database names disallow `/`, `\`, `.`, `"`, `$`, ` `, and NUL.
+7 -4
View File
@@ -25,13 +25,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
tracing::info!("Connecting to MongoDB..."); tracing::info!("Connecting to MongoDB...");
// Per-tenant pool only — the agent has no shared "default" database let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
// after M7.2-D. `mongodb_database` is now the db-name prefix used db.ensure_indexes().await?;
// for tenant databases (`<prefix>_<tenant_id>`).
// M7.2-A: per-tenant pool. Uses `mongodb_database` as the db-name
// prefix so tenant databases land as `<prefix>_<tenant_id>` next to
// the legacy single-tenant database.
let db_pool = let db_pool =
database::DatabasePool::connect(&config.mongodb_uri, &config.mongodb_database).await?; database::DatabasePool::connect(&config.mongodb_uri, &config.mongodb_database).await?;
let agent = agent::ComplianceAgent::new(config.clone(), db_pool); let agent = agent::ComplianceAgent::new(config.clone(), db.clone(), db_pool);
tracing::info!("Starting scheduler..."); tracing::info!("Starting scheduler...");
let scheduler_agent = agent.clone(); let scheduler_agent = agent.clone();
+22 -77
View File
@@ -4,14 +4,8 @@ use tokio_cron_scheduler::{Job, JobScheduler};
use compliance_core::models::ScanTrigger; use compliance_core::models::ScanTrigger;
use crate::agent::ComplianceAgent; use crate::agent::ComplianceAgent;
use crate::database::Database;
use crate::error::AgentError; use crate::error::AgentError;
/// Default tenant the scheduler runs against when `SCHEDULER_TENANT_IDS`
/// isn't set. Matches the dev-injector default so a bare `cargo run` has
/// the scheduler scanning whatever lives in `<prefix>_dev`.
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> { pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
let sched = JobScheduler::new() let sched = JobScheduler::new()
.await .await
@@ -24,9 +18,7 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
let agent = scan_agent.clone(); let agent = scan_agent.clone();
Box::pin(async move { Box::pin(async move {
tracing::info!("Scheduled scan triggered"); tracing::info!("Scheduled scan triggered");
for tenant_id in scheduler_tenants() { scan_all_repos(&agent).await;
scan_all_repos(&agent, &tenant_id).await;
}
}) })
}) })
.map_err(|e| AgentError::Scheduler(format!("Failed to create scan job: {e}")))?; .map_err(|e| AgentError::Scheduler(format!("Failed to create scan job: {e}")))?;
@@ -42,9 +34,7 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
let agent = cve_agent.clone(); let agent = cve_agent.clone();
Box::pin(async move { Box::pin(async move {
tracing::info!("CVE monitor triggered"); tracing::info!("CVE monitor triggered");
for tenant_id in scheduler_tenants() { monitor_cves(&agent).await;
monitor_cves(&agent, &tenant_id).await;
}
}) })
}) })
.map_err(|e| AgentError::Scheduler(format!("Failed to create CVE monitor job: {e}")))?; .map_err(|e| AgentError::Scheduler(format!("Failed to create CVE monitor job: {e}")))?;
@@ -58,9 +48,8 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
.await .await
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?; .map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
let tenants = scheduler_tenants();
tracing::info!( tracing::info!(
"Scheduler started: scans='{}', CVE monitor='{}', tenants={tenants:?}", "Scheduler started: scans='{}', CVE monitor='{}'",
agent.config.scan_schedule, agent.config.scan_schedule,
agent.config.cve_monitor_schedule, agent.config.cve_monitor_schedule,
); );
@@ -71,47 +60,13 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
} }
} }
/// Tenants the scheduler iterates each tick. From `SCHEDULER_TENANT_IDS` async fn scan_all_repos(agent: &ComplianceAgent) {
/// (comma-separated), or `DEFAULT_SCHEDULER_TENANT_ID` if unset. M7.2-D
/// will replace this with a pull from the tenant-registry.
fn scheduler_tenants() -> Vec<String> {
std::env::var("SCHEDULER_TENANT_IDS")
.ok()
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
}
/// Resolve the per-tenant database. Logs and returns `None` on failure
/// so the loop in the caller can continue with other tenants.
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
match agent.db_pool.for_tenant_id(tenant_id).await {
Ok(db) => Some(db),
Err(e) => {
tracing::error!("Scheduler: cannot open tenant database '{tenant_id}': {e}");
None
}
}
}
async fn scan_all_repos(agent: &ComplianceAgent, tenant_id: &str) {
use futures_util::StreamExt; use futures_util::StreamExt;
let db = match tenant_db(agent, tenant_id).await { let cursor = match agent.db.repositories().find(doc! {}).await {
Some(db) => db,
None => return,
};
let cursor = match db.repositories().find(doc! {}).await {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::error!("Failed to list repos for tenant '{tenant_id}': {e}"); tracing::error!("Failed to list repos for scheduled scan: {e}");
return; return;
} }
}; };
@@ -120,44 +75,33 @@ async fn scan_all_repos(agent: &ComplianceAgent, tenant_id: &str) {
for repo in repos { for repo in repos {
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
if let Err(e) = agent if let Err(e) = agent.run_scan(&repo_id, ScanTrigger::Scheduled).await {
.run_scan(tenant_id, &repo_id, ScanTrigger::Scheduled) tracing::error!("Scheduled scan failed for {}: {e}", repo.name);
.await
{
tracing::error!(
"Scheduled scan failed for {} (tenant '{tenant_id}'): {e}",
repo.name
);
} }
} }
} }
async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) { async fn monitor_cves(agent: &ComplianceAgent) {
use compliance_core::models::notification::{parse_severity, CveNotification}; use compliance_core::models::notification::{parse_severity, CveNotification};
use compliance_core::models::SbomEntry; use compliance_core::models::SbomEntry;
use futures_util::StreamExt; use futures_util::StreamExt;
let db = match tenant_db(agent, tenant_id).await {
Some(db) => db,
None => return,
};
// Fetch all SBOM entries grouped by repo // Fetch all SBOM entries grouped by repo
let cursor = match db.sbom_entries().find(doc! {}).await { let cursor = match agent.db.sbom_entries().find(doc! {}).await {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::error!("CVE monitor: failed to list SBOM entries for '{tenant_id}': {e}"); tracing::error!("CVE monitor: failed to list SBOM entries: {e}");
return; return;
} }
}; };
let entries: Vec<SbomEntry> = cursor.filter_map(|r| async { r.ok() }).collect().await; let entries: Vec<SbomEntry> = cursor.filter_map(|r| async { r.ok() }).collect().await;
if entries.is_empty() { if entries.is_empty() {
tracing::debug!("CVE monitor: no SBOM entries for tenant '{tenant_id}', skipping"); tracing::debug!("CVE monitor: no SBOM entries, skipping");
return; return;
} }
tracing::info!( tracing::info!(
"CVE monitor: checking {} dependencies for new CVEs (tenant '{tenant_id}')", "CVE monitor: checking {} dependencies for new CVEs",
entries.len() entries.len()
); );
@@ -168,7 +112,7 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
std::collections::HashMap::new(); std::collections::HashMap::new();
for rid in &repo_ids { for rid in &repo_ids {
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(rid) { if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(rid) {
if let Ok(Some(repo)) = db.repositories().find_one(doc! { "_id": oid }).await { if let Ok(Some(repo)) = agent.db.repositories().find_one(doc! { "_id": oid }).await {
repo_names.insert(rid.clone(), repo.name.clone()); repo_names.insert(rid.clone(), repo.name.clone());
} }
} }
@@ -216,7 +160,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
for alert in &alerts { for alert in &alerts {
let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id }; let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id };
let update = doc! { "$setOnInsert": mongodb::bson::to_bson(alert).unwrap_or_default() }; let update = doc! { "$setOnInsert": mongodb::bson::to_bson(alert).unwrap_or_default() };
let _ = db let _ = agent
.db
.cve_alerts() .cve_alerts()
.update_one(filter, update) .update_one(filter, update)
.upsert(true) .upsert(true)
@@ -229,7 +174,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
continue; continue;
} }
if let Some(entry_id) = &entry.id { if let Some(entry_id) = &entry.id {
let _ = db let _ = agent
.db
.sbom_entries() .sbom_entries()
.update_one( .update_one(
doc! { "_id": entry_id }, doc! { "_id": entry_id },
@@ -267,7 +213,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
let update = doc! { let update = doc! {
"$setOnInsert": mongodb::bson::to_bson(&notification).unwrap_or_default() "$setOnInsert": mongodb::bson::to_bson(&notification).unwrap_or_default()
}; };
match db match agent
.db
.cve_notifications() .cve_notifications()
.update_one(filter, update) .update_one(filter, update)
.upsert(true) .upsert(true)
@@ -285,10 +232,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
} }
if new_notifications > 0 { if new_notifications > 0 {
tracing::info!( tracing::info!("CVE monitor: created {new_notifications} new notification(s)");
"CVE monitor: created {new_notifications} new notification(s) for tenant '{tenant_id}'"
);
} else { } else {
tracing::info!("CVE monitor: no new CVEs found for tenant '{tenant_id}'"); tracing::info!("CVE monitor: no new CVEs found");
} }
} }
+9 -23
View File
@@ -14,30 +14,24 @@ type HmacSha256 = Hmac<Sha256>;
pub async fn handle_gitea_webhook( pub async fn handle_gitea_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>, Extension(agent): Extension<Arc<ComplianceAgent>>,
Path((tenant_id, repo_id)): Path<(String, String)>, Path(repo_id): Path<String>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> StatusCode { ) -> StatusCode {
// Look up the repo in the tenant's database to get its webhook secret // Look up the repo to get its webhook secret
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) { let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
Ok(oid) => oid, Ok(oid) => oid,
Err(_) => return StatusCode::NOT_FOUND, Err(_) => return StatusCode::NOT_FOUND,
}; };
let db = match agent.db_pool.for_tenant_id(&tenant_id).await { let repo = match agent
Ok(db) => db, .db
Err(e) => {
tracing::warn!("Gitea webhook: cannot open tenant database '{tenant_id}': {e}");
return StatusCode::NOT_FOUND;
}
};
let repo = match db
.repositories() .repositories()
.find_one(mongodb::bson::doc! { "_id": oid }) .find_one(mongodb::bson::doc! { "_id": oid })
.await .await
{ {
Ok(Some(repo)) => repo, Ok(Some(repo)) => repo,
_ => { _ => {
tracing::warn!("Gitea webhook: repo {repo_id} not found in tenant '{tenant_id}'"); tracing::warn!("Gitea webhook: repo {repo_id} not found");
return StatusCode::NOT_FOUND; return StatusCode::NOT_FOUND;
} }
}; };
@@ -72,21 +66,15 @@ pub async fn handle_gitea_webhook(
"push" => { "push" => {
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
let repo_id = repo_id.clone(); let repo_id = repo_id.clone();
let tenant_id = tenant_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!( tracing::info!("Gitea push webhook: triggering scan for {repo_id}");
"Gitea push webhook: triggering scan for {repo_id} in tenant {tenant_id}" if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
);
if let Err(e) = agent_clone
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
.await
{
tracing::error!("Webhook-triggered scan failed: {e}"); tracing::error!("Webhook-triggered scan failed: {e}");
} }
}); });
StatusCode::OK StatusCode::OK
} }
"pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await, "pull_request" => handle_pull_request(agent, &repo_id, &payload).await,
_ => { _ => {
tracing::debug!("Gitea webhook: ignoring event '{event}'"); tracing::debug!("Gitea webhook: ignoring event '{event}'");
StatusCode::OK StatusCode::OK
@@ -96,7 +84,6 @@ pub async fn handle_gitea_webhook(
async fn handle_pull_request( async fn handle_pull_request(
agent: Arc<ComplianceAgent>, agent: Arc<ComplianceAgent>,
tenant_id: &str,
repo_id: &str, repo_id: &str,
payload: &serde_json::Value, payload: &serde_json::Value,
) -> StatusCode { ) -> StatusCode {
@@ -119,14 +106,13 @@ async fn handle_pull_request(
} }
let repo_id = repo_id.to_string(); let repo_id = repo_id.to_string();
let tenant_id = tenant_id.to_string();
let head_sha = head_sha.to_string(); let head_sha = head_sha.to_string();
let base_sha = base_sha.to_string(); let base_sha = base_sha.to_string();
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!("Gitea PR webhook: reviewing PR #{pr_number} on {repo_id}"); tracing::info!("Gitea PR webhook: reviewing PR #{pr_number} on {repo_id}");
if let Err(e) = agent_clone if let Err(e) = agent_clone
.run_pr_review(&tenant_id, &repo_id, pr_number, &base_sha, &head_sha) .run_pr_review(&repo_id, pr_number, &base_sha, &head_sha)
.await .await
{ {
tracing::error!("PR review failed for #{pr_number}: {e}"); tracing::error!("PR review failed for #{pr_number}: {e}");
+9 -23
View File
@@ -14,30 +14,24 @@ type HmacSha256 = Hmac<Sha256>;
pub async fn handle_github_webhook( pub async fn handle_github_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>, Extension(agent): Extension<Arc<ComplianceAgent>>,
Path((tenant_id, repo_id)): Path<(String, String)>, Path(repo_id): Path<String>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> StatusCode { ) -> StatusCode {
// Look up the repo in the tenant's database to get its webhook secret // Look up the repo to get its webhook secret
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) { let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
Ok(oid) => oid, Ok(oid) => oid,
Err(_) => return StatusCode::NOT_FOUND, Err(_) => return StatusCode::NOT_FOUND,
}; };
let db = match agent.db_pool.for_tenant_id(&tenant_id).await { let repo = match agent
Ok(db) => db, .db
Err(e) => {
tracing::warn!("GitHub webhook: cannot open tenant database '{tenant_id}': {e}");
return StatusCode::NOT_FOUND;
}
};
let repo = match db
.repositories() .repositories()
.find_one(mongodb::bson::doc! { "_id": oid }) .find_one(mongodb::bson::doc! { "_id": oid })
.await .await
{ {
Ok(Some(repo)) => repo, Ok(Some(repo)) => repo,
_ => { _ => {
tracing::warn!("GitHub webhook: repo {repo_id} not found in tenant '{tenant_id}'"); tracing::warn!("GitHub webhook: repo {repo_id} not found");
return StatusCode::NOT_FOUND; return StatusCode::NOT_FOUND;
} }
}; };
@@ -72,21 +66,15 @@ pub async fn handle_github_webhook(
"push" => { "push" => {
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
let repo_id = repo_id.clone(); let repo_id = repo_id.clone();
let tenant_id = tenant_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!( tracing::info!("GitHub push webhook: triggering scan for {repo_id}");
"GitHub push webhook: triggering scan for {repo_id} in tenant {tenant_id}" if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
);
if let Err(e) = agent_clone
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
.await
{
tracing::error!("Webhook-triggered scan failed: {e}"); tracing::error!("Webhook-triggered scan failed: {e}");
} }
}); });
StatusCode::OK StatusCode::OK
} }
"pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await, "pull_request" => handle_pull_request(agent, &repo_id, &payload).await,
_ => { _ => {
tracing::debug!("GitHub webhook: ignoring event '{event}'"); tracing::debug!("GitHub webhook: ignoring event '{event}'");
StatusCode::OK StatusCode::OK
@@ -96,7 +84,6 @@ pub async fn handle_github_webhook(
async fn handle_pull_request( async fn handle_pull_request(
agent: Arc<ComplianceAgent>, agent: Arc<ComplianceAgent>,
tenant_id: &str,
repo_id: &str, repo_id: &str,
payload: &serde_json::Value, payload: &serde_json::Value,
) -> StatusCode { ) -> StatusCode {
@@ -118,14 +105,13 @@ async fn handle_pull_request(
} }
let repo_id = repo_id.to_string(); let repo_id = repo_id.to_string();
let tenant_id = tenant_id.to_string();
let head_sha = head_sha.to_string(); let head_sha = head_sha.to_string();
let base_sha = base_sha.to_string(); let base_sha = base_sha.to_string();
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!("GitHub PR webhook: reviewing PR #{pr_number} on {repo_id}"); tracing::info!("GitHub PR webhook: reviewing PR #{pr_number} on {repo_id}");
if let Err(e) = agent_clone if let Err(e) = agent_clone
.run_pr_review(&tenant_id, &repo_id, pr_number, &base_sha, &head_sha) .run_pr_review(&repo_id, pr_number, &base_sha, &head_sha)
.await .await
{ {
tracing::error!("PR review failed for #{pr_number}: {e}"); tracing::error!("PR review failed for #{pr_number}: {e}");
+9 -23
View File
@@ -10,30 +10,24 @@ use crate::agent::ComplianceAgent;
pub async fn handle_gitlab_webhook( pub async fn handle_gitlab_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>, Extension(agent): Extension<Arc<ComplianceAgent>>,
Path((tenant_id, repo_id)): Path<(String, String)>, Path(repo_id): Path<String>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> StatusCode { ) -> StatusCode {
// Look up the repo in the tenant's database to get its webhook secret // Look up the repo to get its webhook secret
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) { let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
Ok(oid) => oid, Ok(oid) => oid,
Err(_) => return StatusCode::NOT_FOUND, Err(_) => return StatusCode::NOT_FOUND,
}; };
let db = match agent.db_pool.for_tenant_id(&tenant_id).await { let repo = match agent
Ok(db) => db, .db
Err(e) => {
tracing::warn!("GitLab webhook: cannot open tenant database '{tenant_id}': {e}");
return StatusCode::NOT_FOUND;
}
};
let repo = match db
.repositories() .repositories()
.find_one(mongodb::bson::doc! { "_id": oid }) .find_one(mongodb::bson::doc! { "_id": oid })
.await .await
{ {
Ok(Some(repo)) => repo, Ok(Some(repo)) => repo,
_ => { _ => {
tracing::warn!("GitLab webhook: repo {repo_id} not found in tenant '{tenant_id}'"); tracing::warn!("GitLab webhook: repo {repo_id} not found");
return StatusCode::NOT_FOUND; return StatusCode::NOT_FOUND;
} }
}; };
@@ -65,21 +59,15 @@ pub async fn handle_gitlab_webhook(
"push" => { "push" => {
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
let repo_id = repo_id.clone(); let repo_id = repo_id.clone();
let tenant_id = tenant_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!( tracing::info!("GitLab push webhook: triggering scan for {repo_id}");
"GitLab push webhook: triggering scan for {repo_id} in tenant {tenant_id}" if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
);
if let Err(e) = agent_clone
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
.await
{
tracing::error!("Webhook-triggered scan failed: {e}"); tracing::error!("Webhook-triggered scan failed: {e}");
} }
}); });
StatusCode::OK StatusCode::OK
} }
"merge_request" => handle_merge_request(agent, &tenant_id, &repo_id, &payload).await, "merge_request" => handle_merge_request(agent, &repo_id, &payload).await,
_ => { _ => {
tracing::debug!("GitLab webhook: ignoring event '{event_type}'"); tracing::debug!("GitLab webhook: ignoring event '{event_type}'");
StatusCode::OK StatusCode::OK
@@ -89,7 +77,6 @@ pub async fn handle_gitlab_webhook(
async fn handle_merge_request( async fn handle_merge_request(
agent: Arc<ComplianceAgent>, agent: Arc<ComplianceAgent>,
tenant_id: &str,
repo_id: &str, repo_id: &str,
payload: &serde_json::Value, payload: &serde_json::Value,
) -> StatusCode { ) -> StatusCode {
@@ -114,14 +101,13 @@ async fn handle_merge_request(
} }
let repo_id = repo_id.to_string(); let repo_id = repo_id.to_string();
let tenant_id = tenant_id.to_string();
let head_sha = head_sha.to_string(); let head_sha = head_sha.to_string();
let base_sha = base_sha.to_string(); let base_sha = base_sha.to_string();
let agent_clone = (*agent).clone(); let agent_clone = (*agent).clone();
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!("GitLab MR webhook: reviewing MR !{mr_iid} on {repo_id}"); tracing::info!("GitLab MR webhook: reviewing MR !{mr_iid} on {repo_id}");
if let Err(e) = agent_clone if let Err(e) = agent_clone
.run_pr_review(&tenant_id, &repo_id, mr_iid, &base_sha, &head_sha) .run_pr_review(&repo_id, mr_iid, &base_sha, &head_sha)
.await .await
{ {
tracing::error!("MR review failed for !{mr_iid}: {e}"); tracing::error!("MR review failed for !{mr_iid}: {e}");
+4 -8
View File
@@ -9,21 +9,17 @@ use crate::webhooks::{gitea, github, gitlab};
pub async fn start_webhook_server(agent: &ComplianceAgent) -> Result<(), AgentError> { pub async fn start_webhook_server(agent: &ComplianceAgent) -> Result<(), AgentError> {
let app = Router::new() let app = Router::new()
// Per-tenant per-repo webhook URLs: /webhook/{tenant_id}/{platform}/{repo_id} // Per-repo webhook URLs: /webhook/{platform}/{repo_id}
// The tenant_id is resolved from the URL path because webhooks
// arrive without a JWT — they're authenticated via per-repo HMAC,
// not via the tenant gate. The dashboard surfaces the full URL
// including the tenant_id when the repo is registered.
.route( .route(
"/webhook/{tenant_id}/github/{repo_id}", "/webhook/github/{repo_id}",
post(github::handle_github_webhook), post(github::handle_github_webhook),
) )
.route( .route(
"/webhook/{tenant_id}/gitlab/{repo_id}", "/webhook/gitlab/{repo_id}",
post(gitlab::handle_gitlab_webhook), post(gitlab::handle_gitlab_webhook),
) )
.route( .route(
"/webhook/{tenant_id}/gitea/{repo_id}", "/webhook/gitea/{repo_id}",
post(gitea::handle_gitea_webhook), post(gitea::handle_gitea_webhook),
) )
.layer(Extension(Arc::new(agent.clone()))); .layer(Extension(Arc::new(agent.clone())));
+11 -5
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use compliance_agent::agent::ComplianceAgent; use compliance_agent::agent::ComplianceAgent;
use compliance_agent::api; use compliance_agent::api;
use compliance_agent::database::DatabasePool; use compliance_agent::database::{Database, DatabasePool};
use compliance_core::AgentConfig; use compliance_core::AgentConfig;
use secrecy::SecretString; use secrecy::SecretString;
@@ -28,6 +28,11 @@ impl TestServer {
// Unique database name per test run to avoid collisions // Unique database name per test run to avoid collisions
let db_name = format!("test_{}", uuid::Uuid::new_v4().simple()); let db_name = format!("test_{}", uuid::Uuid::new_v4().simple());
let db = Database::connect(&mongodb_uri, &db_name)
.await
.expect("Failed to connect to MongoDB — is it running?");
db.ensure_indexes().await.expect("Failed to create indexes");
let db_pool = DatabasePool::connect(&mongodb_uri, &db_name) let db_pool = DatabasePool::connect(&mongodb_uri, &db_name)
.await .await
.expect("Failed to build DatabasePool"); .expect("Failed to build DatabasePool");
@@ -68,7 +73,7 @@ impl TestServer {
pentest_imap_password: None, pentest_imap_password: None,
}; };
let agent = ComplianceAgent::new(config, db_pool); let agent = ComplianceAgent::new(config, db, db_pool);
// Build the router with the agent extension. After M7.2-B every // Build the router with the agent extension. After M7.2-B every
// handler takes a TenantCtx extractor; without KC in the test // handler takes a TenantCtx extractor; without KC in the test
@@ -159,11 +164,12 @@ impl TestServer {
&self.db_name &self.db_name
} }
/// Drop every per-tenant database belonging to this test run. /// Drop the test database on cleanup. Post-M7.2-B the actual data
/// Post-M7.2-D the agent never opens a `db_name` directly — /// lives in `<db_name>_<tenant>` per-tenant databases; list those
/// data lives only in `<db_name>_<tenant>` per-tenant databases. /// off the cluster and drop them too.
pub async fn cleanup(&self) { pub async fn cleanup(&self) {
if let Ok(client) = mongodb::Client::with_uri_str(&self.mongodb_uri).await { if let Ok(client) = mongodb::Client::with_uri_str(&self.mongodb_uri).await {
client.database(&self.db_name).drop().await.ok();
if let Ok(names) = client.list_database_names().await { if let Ok(names) = client.list_database_names().await {
let prefix = format!("{}_", self.db_name); let prefix = format!("{}_", self.db_name);
for name in names { for name in names {
@@ -158,70 +158,6 @@ async fn tenant_db_name_sanitizes_unsafe_characters() {
} }
} }
#[tokio::test]
async fn admin_helpers_list_and_drop_tenant_dbs() {
let uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let prefix = format!("m72d_{}", short_id());
let pool = DatabasePool::connect(&uri, &prefix).await.expect("connect");
let acme = ctx("00000000-0000-0000-0000-00000000acme", "acme");
let globex = ctx("00000000-0000-0000-0000-0000globex000", "globex");
// Provision two tenants and write a doc into each so the databases
// actually materialize on the cluster (Mongo lazily creates DBs).
let acme_db = pool.for_tenant(&acme).await.expect("acme db");
let globex_db = pool.for_tenant(&globex).await.expect("globex db");
acme_db
.repositories()
.insert_one(fixture_repo("acme-app", "git@example.com:acme/app.git"))
.await
.expect("insert acme");
globex_db
.repositories()
.insert_one(fixture_repo("globex-app", "git@example.com:globex/app.git"))
.await
.expect("insert globex");
// list_tenant_db_names sees both, filtered by prefix
let names = pool.list_tenant_db_names().await.expect("list tenants");
let acme_name = pool.tenant_db_name(&acme.tenant_id);
let globex_name = pool.tenant_db_name(&globex.tenant_id);
assert!(
names.contains(&acme_name),
"expected {acme_name} in {names:?}"
);
assert!(
names.contains(&globex_name),
"expected {globex_name} in {names:?}"
);
for name in &names {
assert!(name.starts_with(&format!("{prefix}_")));
}
// drop_tenant removes acme's DB
pool.drop_tenant(&acme.tenant_id)
.await
.expect("drop acme tenant");
let after = pool
.list_tenant_db_names()
.await
.expect("list tenants after drop");
assert!(
!after.contains(&acme_name),
"acme should be gone after drop, got {after:?}"
);
assert!(
after.contains(&globex_name),
"globex should still be present, got {after:?}"
);
// Cleanup remaining
pool.drop_tenant(&globex.tenant_id)
.await
.expect("drop globex tenant");
}
#[tokio::test] #[tokio::test]
async fn tenant_db_name_falls_back_to_hash_when_too_long() { async fn tenant_db_name_falls_back_to_hash_when_too_long() {
let uri = std::env::var("TEST_MONGODB_URI") let uri = std::env::var("TEST_MONGODB_URI")
@@ -1,210 +0,0 @@
//! Authenticated HTTP client for talking to the compliance-agent.
//!
//! Every dashboard server function that hits `comp-dev.meghsakha.com/api/v1/*`
//! must go through here so the Keycloak access token from the user's
//! session is attached as `Authorization: Bearer <token>`. Without it
//! the agent's M7.1 `require_jwt_auth` middleware rejects with 401
//! "Missing authorization header".
//!
//! When Keycloak is not configured (dev convenience), the helper
//! returns an unauthenticated builder — matching the agent's
//! pass-through behavior in the same state.
//!
//! **Token refresh**: KC access tokens are short-lived (5 min default
//! in the certifai realm). Before attaching, we decode the JWT's `exp`
//! claim and proactively refresh via the stored refresh_token if the
//! access token is expired or about to expire. The session is updated
//! with the new pair. If refresh fails, we send the (stale) token
//! anyway — the agent's 401 will surface to the UI, which can prompt
//! re-login.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use dioxus::prelude::ServerFnError;
use dioxus_fullstack::FullstackContext;
use reqwest::Method;
use super::auth::LOGGED_IN_USER_SESS_KEY;
use super::server_state::ServerState;
use super::user_state::UserStateInner;
/// Seconds before the JWT's `exp` time at which we consider it stale
/// enough to refresh. Covers clock skew + the round-trip to the agent
/// so the token doesn't expire mid-flight.
const REFRESH_SKEW_SECS: i64 = 30;
/// Build a `RequestBuilder` for `<agent_api_url><path>` with the
/// session's access token attached. `path` should include a leading
/// `/`, e.g. `"/api/v1/repositories"`.
pub async fn agent_request(
method: Method,
path: &str,
) -> Result<reqwest::RequestBuilder, ServerFnError> {
let state: ServerState = FullstackContext::extract().await?;
let url = format!("{}{}", state.agent_api_url, path);
let mut req = reqwest::Client::new().request(method, &url);
req = attach_token(req, &state).await?;
Ok(req)
}
/// Same as [`agent_request`] but for `GET`. Convenience for the common case.
pub async fn agent_get(path: &str) -> Result<reqwest::RequestBuilder, ServerFnError> {
agent_request(Method::GET, path).await
}
/// Attach the session's bearer token if Keycloak is configured AND the
/// session has a logged-in user. Refresh the token proactively if it's
/// expired or about to expire. Persists refreshed tokens back into the
/// session.
async fn attach_token(
req: reqwest::RequestBuilder,
state: &ServerState,
) -> Result<reqwest::RequestBuilder, ServerFnError> {
if state.keycloak.is_none() {
return Ok(req);
}
let session: tower_sessions::Session = FullstackContext::extract().await?;
let user: Option<UserStateInner> = session
.get(LOGGED_IN_USER_SESS_KEY)
.await
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
let Some(mut user) = user else {
return Ok(req);
};
if token_needs_refresh(&user.access_token) {
tracing::debug!("Access token expired or near-expiring; refreshing");
match refresh_tokens(state, &user.refresh_token).await {
Ok((new_access, new_refresh)) => {
user.access_token = new_access;
if let Some(rt) = new_refresh {
user.refresh_token = rt;
}
if let Err(e) = session.insert(LOGGED_IN_USER_SESS_KEY, &user).await {
tracing::warn!("Failed to persist refreshed tokens: {e}");
}
}
Err(e) => {
tracing::warn!("Token refresh failed: {e}; sending current token anyway");
// Fall through — the agent will 401 and the UI will
// prompt re-login. Better than failing the request at
// the dashboard layer with no helpful UX cue.
}
}
}
Ok(req.bearer_auth(user.access_token))
}
/// Decode the JWT's payload (no signature verification — the agent
/// does that) and check the `exp` claim. Treats malformed tokens as
/// expired so the refresh path runs.
fn token_needs_refresh(jwt: &str) -> bool {
let Some(payload_b64) = jwt.split('.').nth(1) else {
return true;
};
let Ok(bytes) = URL_SAFE_NO_PAD.decode(payload_b64) else {
return true;
};
#[derive(serde::Deserialize)]
struct ExpClaim {
exp: i64,
}
let Ok(claims) = serde_json::from_slice::<ExpClaim>(&bytes) else {
return true;
};
let now = chrono::Utc::now().timestamp();
claims.exp - REFRESH_SKEW_SECS <= now
}
/// Exchange a refresh_token for a new access_token. Returns the new
/// access_token and (optionally) the new refresh_token KC issued.
/// KC may rotate refresh_tokens on use; we honor whatever it sends.
async fn refresh_tokens(
state: &ServerState,
refresh_token: &str,
) -> Result<(String, Option<String>), String> {
let kc = state
.keycloak
.ok_or_else(|| "Keycloak not configured".to_string())?;
if refresh_token.is_empty() {
return Err("no refresh_token in session".to_string());
}
#[derive(serde::Deserialize)]
struct TokenResp {
access_token: String,
refresh_token: Option<String>,
}
let resp = reqwest::Client::new()
.post(kc.token_endpoint())
.form(&[
("grant_type", "refresh_token"),
("client_id", kc.client_id.as_str()),
("refresh_token", refresh_token),
])
.send()
.await
.map_err(|e| format!("refresh request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("refresh rejected ({status}): {body}"));
}
let r: TokenResp = resp
.json()
.await
.map_err(|e| format!("refresh response parse failed: {e}"))?;
Ok((r.access_token, r.refresh_token))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
/// Build a JWT-shaped string (header.payload.sig) with the given
/// payload. Signature is bogus — we never verify it locally.
fn make_jwt(payload: &serde_json::Value) -> String {
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(payload).unwrap());
format!("hdr.{payload_b64}.sig")
}
#[test]
fn token_needs_refresh_true_when_expired() {
let exp = chrono::Utc::now().timestamp() - 60;
let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
assert!(token_needs_refresh(&jwt));
}
#[test]
fn token_needs_refresh_true_within_skew_window() {
// 10 seconds left; less than the 30s skew → must refresh.
let exp = chrono::Utc::now().timestamp() + 10;
let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
assert!(token_needs_refresh(&jwt));
}
#[test]
fn token_needs_refresh_false_with_plenty_of_life() {
let exp = chrono::Utc::now().timestamp() + 600;
let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
assert!(!token_needs_refresh(&jwt));
}
#[test]
fn token_needs_refresh_true_on_malformed_jwt() {
assert!(token_needs_refresh(""));
assert!(token_needs_refresh("not.a.jwt"));
assert!(token_needs_refresh("only-one-segment"));
assert!(token_needs_refresh("hdr.not-base64!.sig"));
}
#[test]
fn token_needs_refresh_true_when_exp_missing() {
let jwt = make_jwt(&serde_json::json!({ "sub": "abc" }));
assert!(token_needs_refresh(&jwt));
}
}
+35 -26
View File
@@ -61,21 +61,23 @@ pub async fn send_chat_message(
message: String, message: String,
history: Vec<ChatHistoryMessage>, history: Vec<ChatHistoryMessage>,
) -> Result<ChatApiResponse, ServerFnError> { ) -> Result<ChatApiResponse, ServerFnError> {
// Chat uses a longer timeout because the LLM round-trip can be slow; let state: super::server_state::ServerState =
// agent_request doesn't expose a per-call timeout so we layer one on. dioxus_fullstack::FullstackContext::extract().await?;
let resp = super::agent_client::agent_request(
reqwest::Method::POST, let url = format!("{}/api/v1/chat/{repo_id}", state.agent_api_url);
&format!("/api/v1/chat/{repo_id}"), let client = reqwest::Client::builder()
) .timeout(std::time::Duration::from_secs(120))
.await? .build()
.timeout(std::time::Duration::from_secs(120)) .map_err(|e| ServerFnError::new(e.to_string()))?;
.json(&serde_json::json!({ let resp = client
"message": message, .post(&url)
"history": history, .json(&serde_json::json!({
})) "message": message,
.send() "history": history,
.await }))
.map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?; .send()
.await
.map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?;
let text = resp let text = resp
.text() .text()
@@ -89,14 +91,19 @@ pub async fn send_chat_message(
#[server] #[server]
pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> { pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/chat/{repo_id}/build-embeddings"),
) let url = format!(
.await? "{}/api/v1/chat/{repo_id}/build-embeddings",
.send() state.agent_api_url
.await );
.map_err(|e| ServerFnError::new(e.to_string()))?; let client = reqwest::Client::new();
client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -104,9 +111,11 @@ pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnErro
pub async fn fetch_embedding_status( pub async fn fetch_embedding_status(
repo_id: String, repo_id: String,
) -> Result<EmbeddingStatusResponse, ServerFnError> { ) -> Result<EmbeddingStatusResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/chat/{repo_id}/status")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send()
let url = format!("{}/api/v1/chat/{repo_id}/status", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: EmbeddingStatusResponse = resp let body: EmbeddingStatusResponse = resp
+34 -22
View File
@@ -26,9 +26,10 @@ pub struct DastFindingDetailResponse {
#[server] #[server]
pub async fn fetch_dast_targets() -> Result<DastTargetsResponse, ServerFnError> { pub async fn fetch_dast_targets() -> Result<DastTargetsResponse, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/dast/targets") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastTargetsResponse = resp let body: DastTargetsResponse = resp
@@ -40,9 +41,10 @@ pub async fn fetch_dast_targets() -> Result<DastTargetsResponse, ServerFnError>
#[server] #[server]
pub async fn fetch_dast_scan_runs() -> Result<DastScanRunsResponse, ServerFnError> { pub async fn fetch_dast_scan_runs() -> Result<DastScanRunsResponse, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/dast/scan-runs") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/dast/scan-runs", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastScanRunsResponse = resp let body: DastScanRunsResponse = resp
@@ -54,9 +56,10 @@ pub async fn fetch_dast_scan_runs() -> Result<DastScanRunsResponse, ServerFnErro
#[server] #[server]
pub async fn fetch_dast_findings() -> Result<DastFindingsResponse, ServerFnError> { pub async fn fetch_dast_findings() -> Result<DastFindingsResponse, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/dast/findings") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/dast/findings", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastFindingsResponse = resp let body: DastFindingsResponse = resp
@@ -70,9 +73,10 @@ pub async fn fetch_dast_findings() -> Result<DastFindingsResponse, ServerFnError
pub async fn fetch_dast_finding_detail( pub async fn fetch_dast_finding_detail(
id: String, id: String,
) -> Result<DastFindingDetailResponse, ServerFnError> { ) -> Result<DastFindingDetailResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/dast/findings/{id}")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/dast/findings/{id}", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastFindingDetailResponse = resp let body: DastFindingDetailResponse = resp
@@ -84,8 +88,12 @@ pub async fn fetch_dast_finding_detail(
#[server] #[server]
pub async fn add_dast_target(name: String, base_url: String) -> Result<(), ServerFnError> { pub async fn add_dast_target(name: String, base_url: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/dast/targets") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
let client = reqwest::Client::new();
client
.post(&url)
.json(&serde_json::json!({ .json(&serde_json::json!({
"name": name, "name": name,
"base_url": base_url, "base_url": base_url,
@@ -98,13 +106,17 @@ pub async fn add_dast_target(name: String, base_url: String) -> Result<(), Serve
#[server] #[server]
pub async fn trigger_dast_scan(target_id: String) -> Result<(), ServerFnError> { pub async fn trigger_dast_scan(target_id: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/dast/targets/{target_id}/scan"), let url = format!(
) "{}/api/v1/dast/targets/{target_id}/scan",
.await? state.agent_api_url
.send() );
.await let client = reqwest::Client::new();
.map_err(|e| ServerFnError::new(e.to_string()))?; client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -24,35 +24,39 @@ pub struct FindingsQuery {
#[server] #[server]
pub async fn fetch_findings(query: FindingsQuery) -> Result<FindingsListResponse, ServerFnError> { pub async fn fetch_findings(query: FindingsQuery) -> Result<FindingsListResponse, ServerFnError> {
let mut path = format!("/api/v1/findings?page={}&limit=20", query.page); let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let mut url = format!(
"{}/api/v1/findings?page={}&limit=20",
state.agent_api_url, query.page
);
if !query.severity.is_empty() { if !query.severity.is_empty() {
path.push_str(&format!("&severity={}", query.severity)); url.push_str(&format!("&severity={}", query.severity));
} }
if !query.scan_type.is_empty() { if !query.scan_type.is_empty() {
path.push_str(&format!("&scan_type={}", query.scan_type)); url.push_str(&format!("&scan_type={}", query.scan_type));
} }
if !query.status.is_empty() { if !query.status.is_empty() {
path.push_str(&format!("&status={}", query.status)); url.push_str(&format!("&status={}", query.status));
} }
if !query.repo_id.is_empty() { if !query.repo_id.is_empty() {
path.push_str(&format!("&repo_id={}", query.repo_id)); url.push_str(&format!("&repo_id={}", query.repo_id));
} }
if !query.q.is_empty() { if !query.q.is_empty() {
path.push_str(&format!( url.push_str(&format!(
"&q={}", "&q={}",
url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect::<String>() url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect::<String>()
)); ));
} }
if !query.sort_by.is_empty() { if !query.sort_by.is_empty() {
path.push_str(&format!("&sort_by={}", query.sort_by)); url.push_str(&format!("&sort_by={}", query.sort_by));
} }
if !query.sort_order.is_empty() { if !query.sort_order.is_empty() {
path.push_str(&format!("&sort_order={}", query.sort_order)); url.push_str(&format!("&sort_order={}", query.sort_order));
} }
let resp = super::agent_client::agent_get(&path) let resp = reqwest::get(&url)
.await?
.send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: FindingsListResponse = resp let body: FindingsListResponse = resp
@@ -64,9 +68,11 @@ pub async fn fetch_findings(query: FindingsQuery) -> Result<FindingsListResponse
#[server] #[server]
pub async fn fetch_finding_detail(id: String) -> Result<Finding, ServerFnError> { pub async fn fetch_finding_detail(id: String) -> Result<Finding, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/findings/{id}")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/findings/{id}", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: serde_json::Value = resp let body: serde_json::Value = resp
@@ -80,15 +86,18 @@ pub async fn fetch_finding_detail(id: String) -> Result<Finding, ServerFnError>
#[server] #[server]
pub async fn update_finding_status(id: String, status: String) -> Result<(), ServerFnError> { pub async fn update_finding_status(id: String, status: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::PATCH, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/findings/{id}/status"), let url = format!("{}/api/v1/findings/{id}/status", state.agent_api_url);
)
.await? let client = reqwest::Client::new();
.json(&serde_json::json!({ "status": status })) client
.send() .patch(&url)
.await .json(&serde_json::json!({ "status": status }))
.map_err(|e| ServerFnError::new(e.to_string()))?; .send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -97,25 +106,34 @@ pub async fn bulk_update_finding_status(
ids: Vec<String>, ids: Vec<String>,
status: String, status: String,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
super::agent_client::agent_request(reqwest::Method::PATCH, "/api/v1/findings/bulk-status") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/findings/bulk-status", state.agent_api_url);
let client = reqwest::Client::new();
client
.patch(&url)
.json(&serde_json::json!({ "ids": ids, "status": status })) .json(&serde_json::json!({ "ids": ids, "status": status }))
.send() .send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
#[server] #[server]
pub async fn update_finding_feedback(id: String, feedback: String) -> Result<(), ServerFnError> { pub async fn update_finding_feedback(id: String, feedback: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::PATCH, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/findings/{id}/feedback"), let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url);
)
.await? let client = reqwest::Client::new();
.json(&serde_json::json!({ "feedback": feedback })) client
.send() .patch(&url)
.await .json(&serde_json::json!({ "feedback": feedback }))
.map_err(|e| ServerFnError::new(e.to_string()))?; .send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -50,9 +50,10 @@ pub struct SearchResponse {
#[server] #[server]
pub async fn fetch_graph(repo_id: String) -> Result<GraphDataResponse, ServerFnError> { pub async fn fetch_graph(repo_id: String) -> Result<GraphDataResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/graph/{repo_id}")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/graph/{repo_id}", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: GraphDataResponse = resp let body: GraphDataResponse = resp
@@ -67,12 +68,15 @@ pub async fn fetch_impact(
repo_id: String, repo_id: String,
finding_id: String, finding_id: String,
) -> Result<ImpactResponse, ServerFnError> { ) -> Result<ImpactResponse, ServerFnError> {
let resp = let state: super::server_state::ServerState =
super::agent_client::agent_get(&format!("/api/v1/graph/{repo_id}/impact/{finding_id}")) dioxus_fullstack::FullstackContext::extract().await?;
.await? let url = format!(
.send() "{}/api/v1/graph/{repo_id}/impact/{finding_id}",
.await state.agent_api_url
.map_err(|e| ServerFnError::new(e.to_string()))?; );
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: ImpactResponse = resp let body: ImpactResponse = resp
.json() .json()
.await .await
@@ -82,9 +86,10 @@ pub async fn fetch_impact(
#[server] #[server]
pub async fn fetch_communities(repo_id: String) -> Result<CommunitiesResponse, ServerFnError> { pub async fn fetch_communities(repo_id: String) -> Result<CommunitiesResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/graph/{repo_id}/communities")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/graph/{repo_id}/communities", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: CommunitiesResponse = resp let body: CommunitiesResponse = resp
@@ -99,13 +104,15 @@ pub async fn fetch_file_content(
repo_id: String, repo_id: String,
file_path: String, file_path: String,
) -> Result<FileContentResponse, ServerFnError> { ) -> Result<FileContentResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!( let state: super::server_state::ServerState =
"/api/v1/graph/{repo_id}/file-content?path={file_path}" dioxus_fullstack::FullstackContext::extract().await?;
)) let url = format!(
.await? "{}/api/v1/graph/{repo_id}/file-content?path={file_path}",
.send() state.agent_api_url
.await );
.map_err(|e| ServerFnError::new(e.to_string()))?; let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: FileContentResponse = resp let body: FileContentResponse = resp
.json() .json()
.await .await
@@ -115,13 +122,15 @@ pub async fn fetch_file_content(
#[server] #[server]
pub async fn search_nodes(repo_id: String, query: String) -> Result<SearchResponse, ServerFnError> { pub async fn search_nodes(repo_id: String, query: String) -> Result<SearchResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!( let state: super::server_state::ServerState =
"/api/v1/graph/{repo_id}/search?q={query}&limit=50" dioxus_fullstack::FullstackContext::extract().await?;
)) let url = format!(
.await? "{}/api/v1/graph/{repo_id}/search?q={query}&limit=50",
.send() state.agent_api_url
.await );
.map_err(|e| ServerFnError::new(e.to_string()))?; let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: SearchResponse = resp let body: SearchResponse = resp
.json() .json()
.await .await
@@ -131,13 +140,14 @@ pub async fn search_nodes(repo_id: String, query: String) -> Result<SearchRespon
#[server] #[server]
pub async fn trigger_graph_build(repo_id: String) -> Result<(), ServerFnError> { pub async fn trigger_graph_build(repo_id: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/graph/{repo_id}/build"), let url = format!("{}/api/v1/graph/{repo_id}/build", state.agent_api_url);
) let client = reqwest::Client::new();
.await? client
.send() .post(&url)
.await .send()
.map_err(|e| ServerFnError::new(e.to_string()))?; .await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -12,9 +12,11 @@ pub struct IssuesListResponse {
#[server] #[server]
pub async fn fetch_issues(page: u64) -> Result<IssuesListResponse, ServerFnError> { pub async fn fetch_issues(page: u64) -> Result<IssuesListResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/issues?page={page}&limit=20")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/issues?page={page}&limit=20", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: IssuesListResponse = resp let body: IssuesListResponse = resp
@@ -18,8 +18,6 @@ pub mod stats;
// Server-only modules // Server-only modules
#[cfg(feature = "server")] #[cfg(feature = "server")]
mod agent_client;
#[cfg(feature = "server")]
mod auth; mod auth;
#[cfg(feature = "server")] #[cfg(feature = "server")]
mod auth_middleware; mod auth_middleware;
@@ -32,9 +32,11 @@ pub struct NotificationCountResponse {
#[server] #[server]
pub async fn fetch_notification_count() -> Result<u64, ServerFnError> { pub async fn fetch_notification_count() -> Result<u64, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/notifications/count") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send()
let url = format!("{}/api/v1/notifications/count", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: NotificationCountResponse = resp let body: NotificationCountResponse = resp
@@ -46,9 +48,11 @@ pub async fn fetch_notification_count() -> Result<u64, ServerFnError> {
#[server] #[server]
pub async fn fetch_notifications() -> Result<NotificationListResponse, ServerFnError> { pub async fn fetch_notifications() -> Result<NotificationListResponse, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/notifications?limit=20") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send()
let url = format!("{}/api/v1/notifications?limit=20", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: NotificationListResponse = resp let body: NotificationListResponse = resp
@@ -60,8 +64,12 @@ pub async fn fetch_notifications() -> Result<NotificationListResponse, ServerFnE
#[server] #[server]
pub async fn mark_all_notifications_read() -> Result<(), ServerFnError> { pub async fn mark_all_notifications_read() -> Result<(), ServerFnError> {
super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/notifications/read-all") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/notifications/read-all", state.agent_api_url);
reqwest::Client::new()
.post(&url)
.send() .send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
@@ -70,13 +78,14 @@ pub async fn mark_all_notifications_read() -> Result<(), ServerFnError> {
#[server] #[server]
pub async fn dismiss_notification(id: String) -> Result<(), ServerFnError> { pub async fn dismiss_notification(id: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::PATCH, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/notifications/{id}/dismiss"),
) let url = format!("{}/api/v1/notifications/{id}/dismiss", state.agent_api_url);
.await? reqwest::Client::new()
.send() .patch(&url)
.await .send()
.map_err(|e| ServerFnError::new(e.to_string()))?; .await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
+184 -145
View File
@@ -32,10 +32,12 @@ pub struct AttackChainResponse {
#[server] #[server]
pub async fn fetch_pentest_sessions() -> Result<PentestSessionsResponse, ServerFnError> { pub async fn fetch_pentest_sessions() -> Result<PentestSessionsResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
// Fetch sessions // Fetch sessions
let resp = super::agent_client::agent_get("/api/v1/pentest/sessions") let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
.await? let resp = reqwest::get(&url)
.send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let mut body: PentestSessionsResponse = resp let mut body: PentestSessionsResponse = resp
@@ -44,32 +46,31 @@ pub async fn fetch_pentest_sessions() -> Result<PentestSessionsResponse, ServerF
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
// Fetch DAST targets to resolve target names // Fetch DAST targets to resolve target names
if let Ok(tresp_builder) = super::agent_client::agent_get("/api/v1/dast/targets").await { let targets_url = format!("{}/api/v1/dast/targets", state.agent_api_url);
if let Ok(tresp) = tresp_builder.send().await { if let Ok(tresp) = reqwest::get(&targets_url).await {
if let Ok(tbody) = tresp.json::<serde_json::Value>().await { if let Ok(tbody) = tresp.json::<serde_json::Value>().await {
let targets = tbody.get("data").and_then(|v| v.as_array()); let targets = tbody.get("data").and_then(|v| v.as_array());
if let Some(targets) = targets { if let Some(targets) = targets {
// Build target_id -> name lookup // Build target_id -> name lookup
let target_map: std::collections::HashMap<String, String> = targets let target_map: std::collections::HashMap<String, String> = targets
.iter() .iter()
.filter_map(|t| { .filter_map(|t| {
let id = t.get("_id")?.get("$oid")?.as_str()?.to_string(); let id = t.get("_id")?.get("$oid")?.as_str()?.to_string();
let name = t.get("name")?.as_str()?.to_string(); let name = t.get("name")?.as_str()?.to_string();
Some((id, name)) Some((id, name))
}) })
.collect(); .collect();
// Enrich sessions with target_name // Enrich sessions with target_name
for session in body.data.iter_mut() { for session in body.data.iter_mut() {
if let Some(tid) = session.get("target_id").and_then(|v| v.as_str()) { if let Some(tid) = session.get("target_id").and_then(|v| v.as_str()) {
if let Some(name) = target_map.get(tid) { if let Some(name) = target_map.get(tid) {
session.as_object_mut().map(|obj| { session.as_object_mut().map(|obj| {
obj.insert( obj.insert(
"target_name".to_string(), "target_name".to_string(),
serde_json::Value::String(name.clone()), serde_json::Value::String(name.clone()),
) )
}); });
}
} }
} }
} }
@@ -82,9 +83,10 @@ pub async fn fetch_pentest_sessions() -> Result<PentestSessionsResponse, ServerF
#[server] #[server]
pub async fn fetch_pentest_session(id: String) -> Result<PentestSessionResponse, ServerFnError> { pub async fn fetch_pentest_session(id: String) -> Result<PentestSessionResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/pentest/sessions/{id}")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/pentest/sessions/{id}", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let mut body: PentestSessionResponse = resp let mut body: PentestSessionResponse = resp
@@ -94,27 +96,26 @@ pub async fn fetch_pentest_session(id: String) -> Result<PentestSessionResponse,
// Resolve target name from targets list // Resolve target name from targets list
if let Some(tid) = body.data.get("target_id").and_then(|v| v.as_str()) { if let Some(tid) = body.data.get("target_id").and_then(|v| v.as_str()) {
if let Ok(tresp_builder) = super::agent_client::agent_get("/api/v1/dast/targets").await { let targets_url = format!("{}/api/v1/dast/targets", state.agent_api_url);
if let Ok(tresp) = tresp_builder.send().await { if let Ok(tresp) = reqwest::get(&targets_url).await {
if let Ok(tbody) = tresp.json::<serde_json::Value>().await { if let Ok(tbody) = tresp.json::<serde_json::Value>().await {
if let Some(targets) = tbody.get("data").and_then(|v| v.as_array()) { if let Some(targets) = tbody.get("data").and_then(|v| v.as_array()) {
for t in targets { for t in targets {
let t_id = t let t_id = t
.get("_id") .get("_id")
.and_then(|v| v.get("$oid")) .and_then(|v| v.get("$oid"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
if t_id == tid { if t_id == tid {
if let Some(name) = t.get("name").and_then(|v| v.as_str()) { if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
body.data.as_object_mut().map(|obj| { body.data.as_object_mut().map(|obj| {
obj.insert( obj.insert(
"target_name".to_string(), "target_name".to_string(),
serde_json::Value::String(name.to_string()), serde_json::Value::String(name.to_string()),
) )
}); });
}
break;
} }
break;
} }
} }
} }
@@ -129,12 +130,15 @@ pub async fn fetch_pentest_session(id: String) -> Result<PentestSessionResponse,
pub async fn fetch_pentest_messages( pub async fn fetch_pentest_messages(
session_id: String, session_id: String,
) -> Result<PentestMessagesResponse, ServerFnError> { ) -> Result<PentestMessagesResponse, ServerFnError> {
let resp = let state: super::server_state::ServerState =
super::agent_client::agent_get(&format!("/api/v1/pentest/sessions/{session_id}/messages")) dioxus_fullstack::FullstackContext::extract().await?;
.await? let url = format!(
.send() "{}/api/v1/pentest/sessions/{session_id}/messages",
.await state.agent_api_url
.map_err(|e| ServerFnError::new(e.to_string()))?; );
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: PentestMessagesResponse = resp let body: PentestMessagesResponse = resp
.json() .json()
.await .await
@@ -144,9 +148,10 @@ pub async fn fetch_pentest_messages(
#[server] #[server]
pub async fn fetch_pentest_stats() -> Result<PentestStatsResponse, ServerFnError> { pub async fn fetch_pentest_stats() -> Result<PentestStatsResponse, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/pentest/stats") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/pentest/stats", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: PentestStatsResponse = resp let body: PentestStatsResponse = resp
@@ -158,13 +163,15 @@ pub async fn fetch_pentest_stats() -> Result<PentestStatsResponse, ServerFnError
#[server] #[server]
pub async fn fetch_attack_chain(session_id: String) -> Result<AttackChainResponse, ServerFnError> { pub async fn fetch_attack_chain(session_id: String) -> Result<AttackChainResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!( let state: super::server_state::ServerState =
"/api/v1/pentest/sessions/{session_id}/attack-chain" dioxus_fullstack::FullstackContext::extract().await?;
)) let url = format!(
.await? "{}/api/v1/pentest/sessions/{session_id}/attack-chain",
.send() state.agent_api_url
.await );
.map_err(|e| ServerFnError::new(e.to_string()))?; let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: AttackChainResponse = resp let body: AttackChainResponse = resp
.json() .json()
.await .await
@@ -178,17 +185,20 @@ pub async fn create_pentest_session(
strategy: String, strategy: String,
message: String, message: String,
) -> Result<PentestSessionResponse, ServerFnError> { ) -> Result<PentestSessionResponse, ServerFnError> {
let resp = let state: super::server_state::ServerState =
super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/pentest/sessions") dioxus_fullstack::FullstackContext::extract().await?;
.await? let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
.json(&serde_json::json!({ let client = reqwest::Client::new();
"target_id": target_id, let resp = client
"strategy": strategy, .post(&url)
"message": message, .json(&serde_json::json!({
})) "target_id": target_id,
.send() "strategy": strategy,
.await "message": message,
.map_err(|e| ServerFnError::new(e.to_string()))?; }))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: PentestSessionResponse = resp let body: PentestSessionResponse = resp
.json() .json()
.await .await
@@ -201,15 +211,18 @@ pub async fn create_pentest_session(
pub async fn create_pentest_session_wizard( pub async fn create_pentest_session_wizard(
config_json: String, config_json: String,
) -> Result<PentestSessionResponse, ServerFnError> { ) -> Result<PentestSessionResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
let config: serde_json::Value = let config: serde_json::Value =
serde_json::from_str(&config_json).map_err(|e| ServerFnError::new(e.to_string()))?; serde_json::from_str(&config_json).map_err(|e| ServerFnError::new(e.to_string()))?;
let resp = let client = reqwest::Client::new();
super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/pentest/sessions") let resp = client
.await? .post(&url)
.json(&serde_json::json!({ "config": config })) .json(&serde_json::json!({ "config": config }))
.send() .send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!( return Err(ServerFnError::new(format!(
@@ -226,6 +239,8 @@ pub async fn create_pentest_session_wizard(
/// Look up a tracked repository by its git URL /// Look up a tracked repository by its git URL
#[server] #[server]
pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, ServerFnError> { pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let encoded_url: String = url let encoded_url: String = url
.bytes() .bytes()
.flat_map(|b| { .flat_map(|b| {
@@ -236,12 +251,13 @@ pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, Server
} }
}) })
.collect(); .collect();
let resp = let api_url = format!(
super::agent_client::agent_get(&format!("/api/v1/pentest/lookup-repo?url={encoded_url}")) "{}/api/v1/pentest/lookup-repo?url={}",
.await? state.agent_api_url, encoded_url
.send() );
.await let resp = reqwest::get(&api_url)
.map_err(|e| ServerFnError::new(e.to_string()))?; .await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: serde_json::Value = resp let body: serde_json::Value = resp
.json() .json()
.await .await
@@ -254,17 +270,21 @@ pub async fn send_pentest_message(
session_id: String, session_id: String,
message: String, message: String,
) -> Result<PentestMessagesResponse, ServerFnError> { ) -> Result<PentestMessagesResponse, ServerFnError> {
let resp = super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/pentest/sessions/{session_id}/chat"), let url = format!(
) "{}/api/v1/pentest/sessions/{session_id}/chat",
.await? state.agent_api_url
.json(&serde_json::json!({ );
"message": message, let client = reqwest::Client::new();
})) let resp = client
.send() .post(&url)
.await .json(&serde_json::json!({
.map_err(|e| ServerFnError::new(e.to_string()))?; "message": message,
}))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: PentestMessagesResponse = resp let body: PentestMessagesResponse = resp
.json() .json()
.await .await
@@ -274,27 +294,35 @@ pub async fn send_pentest_message(
#[server] #[server]
pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnError> { pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/pentest/sessions/{session_id}/stop"), let url = format!(
) "{}/api/v1/pentest/sessions/{session_id}/stop",
.await? state.agent_api_url
.send() );
.await let client = reqwest::Client::new();
.map_err(|e| ServerFnError::new(e.to_string()))?; client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
#[server] #[server]
pub async fn pause_pentest_session(session_id: String) -> Result<(), ServerFnError> { pub async fn pause_pentest_session(session_id: String) -> Result<(), ServerFnError> {
let resp = super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/pentest/sessions/{session_id}/pause"), let url = format!(
) "{}/api/v1/pentest/sessions/{session_id}/pause",
.await? state.agent_api_url
.send() );
.await let client = reqwest::Client::new();
.map_err(|e| ServerFnError::new(e.to_string()))?; let resp = client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("Pause failed: {text}"))); return Err(ServerFnError::new(format!("Pause failed: {text}")));
@@ -304,14 +332,18 @@ pub async fn pause_pentest_session(session_id: String) -> Result<(), ServerFnErr
#[server] #[server]
pub async fn resume_pentest_session(session_id: String) -> Result<(), ServerFnError> { pub async fn resume_pentest_session(session_id: String) -> Result<(), ServerFnError> {
let resp = super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/pentest/sessions/{session_id}/resume"), let url = format!(
) "{}/api/v1/pentest/sessions/{session_id}/resume",
.await? state.agent_api_url
.send() );
.await let client = reqwest::Client::new();
.map_err(|e| ServerFnError::new(e.to_string()))?; let resp = client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("Resume failed: {text}"))); return Err(ServerFnError::new(format!("Resume failed: {text}")));
@@ -323,12 +355,15 @@ pub async fn resume_pentest_session(session_id: String) -> Result<(), ServerFnEr
pub async fn fetch_pentest_findings( pub async fn fetch_pentest_findings(
session_id: String, session_id: String,
) -> Result<DastFindingsResponse, ServerFnError> { ) -> Result<DastFindingsResponse, ServerFnError> {
let resp = let state: super::server_state::ServerState =
super::agent_client::agent_get(&format!("/api/v1/pentest/sessions/{session_id}/findings")) dioxus_fullstack::FullstackContext::extract().await?;
.await? let url = format!(
.send() "{}/api/v1/pentest/sessions/{session_id}/findings",
.await state.agent_api_url
.map_err(|e| ServerFnError::new(e.to_string()))?; );
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastFindingsResponse = resp let body: DastFindingsResponse = resp
.json() .json()
.await .await
@@ -350,19 +385,23 @@ pub async fn export_pentest_report(
requester_name: String, requester_name: String,
requester_email: String, requester_email: String,
) -> Result<ExportReportResponse, ServerFnError> { ) -> Result<ExportReportResponse, ServerFnError> {
let resp = super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/pentest/sessions/{session_id}/export"), let url = format!(
) "{}/api/v1/pentest/sessions/{session_id}/export",
.await? state.agent_api_url
.json(&serde_json::json!({ );
"password": password, let client = reqwest::Client::new();
"requester_name": requester_name, let resp = client
"requester_email": requester_email, .post(&url)
})) .json(&serde_json::json!({
.send() "password": password,
.await "requester_name": requester_name,
.map_err(|e| ServerFnError::new(e.to_string()))?; "requester_email": requester_email,
}))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!("Export failed: {text}"))); return Err(ServerFnError::new(format!("Export failed: {text}")));
@@ -12,10 +12,14 @@ pub struct RepositoryListResponse {
#[server] #[server]
pub async fn fetch_repositories(page: u64) -> Result<RepositoryListResponse, ServerFnError> { pub async fn fetch_repositories(page: u64) -> Result<RepositoryListResponse, ServerFnError> {
let path = format!("/api/v1/repositories?page={page}&limit=20"); let state: super::server_state::ServerState =
let resp = super::agent_client::agent_get(&path) dioxus_fullstack::FullstackContext::extract().await?;
.await? let url = format!(
.send() "{}/api/v1/repositories?page={page}&limit=20",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: RepositoryListResponse = resp let body: RepositoryListResponse = resp
@@ -37,6 +41,10 @@ pub async fn add_repository(
tracker_repo: Option<String>, tracker_repo: Option<String>,
tracker_token: Option<String>, tracker_token: Option<String>,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/repositories", state.agent_api_url);
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"name": name, "name": name,
"git_url": git_url, "git_url": git_url,
@@ -61,8 +69,9 @@ pub async fn add_repository(
body["tracker_token"] = serde_json::Value::String(tk); body["tracker_token"] = serde_json::Value::String(tk);
} }
let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/repositories") let client = reqwest::Client::new();
.await? let resp = client
.post(&url)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -91,6 +100,10 @@ pub async fn update_repository(
tracker_token: Option<String>, tracker_token: Option<String>,
scan_schedule: Option<String>, scan_schedule: Option<String>,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url);
let mut body = serde_json::Map::new(); let mut body = serde_json::Map::new();
if let Some(v) = name.filter(|s| !s.is_empty()) { if let Some(v) = name.filter(|s| !s.is_empty()) {
body.insert("name".into(), serde_json::Value::String(v)); body.insert("name".into(), serde_json::Value::String(v));
@@ -120,15 +133,13 @@ pub async fn update_repository(
body.insert("scan_schedule".into(), serde_json::Value::String(v)); body.insert("scan_schedule".into(), serde_json::Value::String(v));
} }
let resp = super::agent_client::agent_request( let client = reqwest::Client::new();
reqwest::Method::PATCH, let resp = client
&format!("/api/v1/repositories/{repo_id}"), .patch(&url)
) .json(&body)
.await? .send()
.json(&body) .await
.send() .map_err(|e| ServerFnError::new(e.to_string()))?;
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
@@ -142,9 +153,11 @@ pub async fn update_repository(
#[server] #[server]
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> { pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/settings/ssh-public-key") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
@@ -166,14 +179,16 @@ pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
#[server] #[server]
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> { pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
let resp = super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::DELETE, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/repositories/{repo_id}"), let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url);
)
.await? let client = reqwest::Client::new();
.send() let resp = client
.await .delete(&url)
.map_err(|e| ServerFnError::new(e.to_string()))?; .send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
@@ -187,14 +202,16 @@ pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
#[server] #[server]
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
super::agent_client::agent_request( let state: super::server_state::ServerState =
reqwest::Method::POST, dioxus_fullstack::FullstackContext::extract().await?;
&format!("/api/v1/repositories/{repo_id}/scan"), let url = format!("{}/api/v1/repositories/{repo_id}/scan", state.agent_api_url);
)
.await? let client = reqwest::Client::new();
.send() client
.await .post(&url)
.map_err(|e| ServerFnError::new(e.to_string()))?; .send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -207,12 +224,16 @@ pub struct WebhookConfigResponse {
#[server] #[server]
pub async fn fetch_webhook_config(repo_id: String) -> Result<WebhookConfigResponse, ServerFnError> { pub async fn fetch_webhook_config(repo_id: String) -> Result<WebhookConfigResponse, ServerFnError> {
let resp = let state: super::server_state::ServerState =
super::agent_client::agent_get(&format!("/api/v1/repositories/{repo_id}/webhook-config")) dioxus_fullstack::FullstackContext::extract().await?;
.await? let url = format!(
.send() "{}/api/v1/repositories/{repo_id}/webhook-config",
.await state.agent_api_url
.map_err(|e| ServerFnError::new(e.to_string()))?; );
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: WebhookConfigResponse = resp let body: WebhookConfigResponse = resp
.json() .json()
.await .await
@@ -223,9 +244,11 @@ pub async fn fetch_webhook_config(repo_id: String) -> Result<WebhookConfigRespon
/// Check if a repository has any running scans /// Check if a repository has any running scans
#[server] #[server]
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> { pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/scan-runs?page=1&limit=1") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/scan-runs?page=1&limit=1", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: serde_json::Value = resp let body: serde_json::Value = resp
+35 -20
View File
@@ -87,9 +87,11 @@ pub struct SbomFiltersResponse {
#[server] #[server]
pub async fn fetch_sbom_filters() -> Result<SbomFiltersResponse, ServerFnError> { pub async fn fetch_sbom_filters() -> Result<SbomFiltersResponse, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/sbom/filters") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send()
let url = format!("{}/api/v1/sbom/filters", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let text = resp let text = resp
@@ -110,6 +112,9 @@ pub async fn fetch_sbom_filtered(
license: Option<String>, license: Option<String>,
page: u64, page: u64,
) -> Result<SbomListResponse, ServerFnError> { ) -> Result<SbomListResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let mut params = vec![format!("page={page}"), "limit=50".to_string()]; let mut params = vec![format!("page={page}"), "limit=50".to_string()];
if let Some(r) = &repo_id { if let Some(r) = &repo_id {
if !r.is_empty() { if !r.is_empty() {
@@ -135,10 +140,9 @@ pub async fn fetch_sbom_filtered(
} }
} }
let path = format!("/api/v1/sbom?{}", params.join("&")); let url = format!("{}/api/v1/sbom?{}", state.agent_api_url, params.join("&"));
let resp = super::agent_client::agent_get(&path)
.await? let resp = reqwest::get(&url)
.send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let text = resp let text = resp
@@ -152,10 +156,15 @@ pub async fn fetch_sbom_filtered(
#[server] #[server]
pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result<String, ServerFnError> { pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result<String, ServerFnError> {
let path = format!("/api/v1/sbom/export?repo_id={repo_id}&format={format}"); let state: super::server_state::ServerState =
let resp = super::agent_client::agent_get(&path) dioxus_fullstack::FullstackContext::extract().await?;
.await?
.send() let url = format!(
"{}/api/v1/sbom/export?repo_id={}&format={}",
state.agent_api_url, repo_id, format
);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let text = resp let text = resp
@@ -169,16 +178,17 @@ pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result<String
pub async fn fetch_license_summary( pub async fn fetch_license_summary(
repo_id: Option<String>, repo_id: Option<String>,
) -> Result<LicenseSummaryResponse, ServerFnError> { ) -> Result<LicenseSummaryResponse, ServerFnError> {
let mut path = "/api/v1/sbom/licenses".to_string(); let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let mut url = format!("{}/api/v1/sbom/licenses", state.agent_api_url);
if let Some(r) = &repo_id { if let Some(r) = &repo_id {
if !r.is_empty() { if !r.is_empty() {
path = format!("{path}?repo_id={r}"); url = format!("{url}?repo_id={r}");
} }
} }
let resp = super::agent_client::agent_get(&path) let resp = reqwest::get(&url)
.await?
.send()
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let text = resp let text = resp
@@ -195,10 +205,15 @@ pub async fn fetch_sbom_diff(
repo_a: String, repo_a: String,
repo_b: String, repo_b: String,
) -> Result<SbomDiffResponse, ServerFnError> { ) -> Result<SbomDiffResponse, ServerFnError> {
let path = format!("/api/v1/sbom/diff?repo_a={repo_a}&repo_b={repo_b}"); let state: super::server_state::ServerState =
let resp = super::agent_client::agent_get(&path) dioxus_fullstack::FullstackContext::extract().await?;
.await?
.send() let url = format!(
"{}/api/v1/sbom/diff?repo_a={}&repo_b={}",
state.agent_api_url, repo_a, repo_b
);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let text = resp let text = resp
@@ -12,9 +12,14 @@ pub struct ScansListResponse {
#[server] #[server]
pub async fn fetch_scan_runs(page: u64) -> Result<ScansListResponse, ServerFnError> { pub async fn fetch_scan_runs(page: u64) -> Result<ScansListResponse, ServerFnError> {
let resp = super::agent_client::agent_get(&format!("/api/v1/scan-runs?page={page}&limit=20")) let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!(
"{}/api/v1/scan-runs?page={page}&limit=20",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: ScansListResponse = resp let body: ScansListResponse = resp
@@ -16,9 +16,11 @@ pub struct OverviewStats {
#[server] #[server]
pub async fn fetch_overview_stats() -> Result<OverviewStats, ServerFnError> { pub async fn fetch_overview_stats() -> Result<OverviewStats, ServerFnError> {
let resp = super::agent_client::agent_get("/api/v1/stats/overview") let state: super::server_state::ServerState =
.await? dioxus_fullstack::FullstackContext::extract().await?;
.send() let url = format!("{}/api/v1/stats/overview", state.agent_api_url);
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let body: serde_json::Value = resp let body: serde_json::Value = resp