Compare commits

..

2 Commits

Author SHA1 Message Date
Sharang Parnerkar bec47f8c7d feat(dashboard): proactively refresh expired Keycloak tokens
CI / Check (pull_request) Successful in 8m7s
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
The dashboard stored a refresh_token in the session at login (auth.rs)
but never used it. Once the access_token's 5-minute lifespan ran out,
every subsequent agent call failed with 401 ExpiredSignature. The UI
showed "unable to load X" until the user logged out and back in.

Fix: before attaching the bearer, decode the JWT's `exp` claim and
proactively refresh via the stored refresh_token if the token is
expired or within REFRESH_SKEW_SECS (30s) of expiry. Updates the
session with the new access_token (and rotated refresh_token if KC
sends one). Refresh failures fall through with the stale token so the
agent's 401 surfaces to the UI rather than failing the request at the
dashboard layer.

Why "proactive" instead of "retry on 401"
- Saves a wasted round-trip on every agent call once the token has
  aged past 5 min.
- Doesn't require cloning RequestBuilder bodies for retry.
- Same end state — fresh token reaches the agent.

Test plan
- cargo test -p compliance-dashboard --features server
  --no-default-features infrastructure::agent_client::tests — 5 pass:
    * expired JWT → refresh
    * near-expiry within skew window → refresh
    * fresh JWT → no refresh
    * malformed/empty JWT → refresh (defensive)
    * JWT without exp claim → refresh (defensive)
- Manual after deploy: dashboard works past the 5-min token lifespan
  without manual re-login.

Note
- The refresh code addresses the ExpiredSignature failure mode. The
  separate "JWT is missing tenant_id claim" 401 is a Keycloak realm
  config issue (the user logging in lacks the M7.1 attributes that
  the protocol mappers consume) and is fixed by realm/attribute
  config, not by this PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 21:38:06 +02:00
sharang 56482911b8 fix(dashboard): attach Keycloak token on agent API calls (#90)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 6s
CI / Deploy Agent (push) Successful in 4m8s
CI / Deploy Dashboard (push) Successful in 4m58s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-06-17 18:35:59 +00:00
24 changed files with 858 additions and 592 deletions
+14 -23
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::{Database, DatabasePool}; use crate::database::DatabasePool;
use crate::llm::LlmClient; use crate::llm::LlmClient;
use crate::pipeline::orchestrator::PipelineOrchestrator; use crate::pipeline::orchestrator::PipelineOrchestrator;
@@ -16,12 +16,9 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 5;
#[derive(Clone)] #[derive(Clone)]
pub struct ComplianceAgent { pub struct ComplianceAgent {
pub config: AgentConfig, pub config: AgentConfig,
/// Transitional single-database handle. Used by handlers that have /// Per-tenant Mongo broker. Every code path must obtain a
/// not yet been migrated to `db_pool.for_tenant(&ctx)` (M7.2-B/C). /// tenant-scoped [`crate::database::Database`] from this pool —
/// Will be removed once every call site is tenant-scoped (M7.2-D). /// there is no single shared database any more.
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,
@@ -34,7 +31,7 @@ pub struct ComplianceAgent {
} }
impl ComplianceAgent { impl ComplianceAgent {
pub fn new(config: AgentConfig, db: Database, db_pool: DatabasePool) -> Self { pub fn new(config: AgentConfig, 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(),
@@ -48,7 +45,6 @@ impl ComplianceAgent {
.unwrap_or_default(); .unwrap_or_default();
Self { Self {
config, config,
db,
db_pool, db_pool,
llm, llm,
http, http,
@@ -60,28 +56,27 @@ 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 orchestrator = PipelineOrchestrator::new( let db = self.db_pool.for_tenant_id(tenant_id).await?;
self.config.clone(), let orchestrator =
self.db.clone(), PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.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 repo = self let db = self.db_pool.for_tenant_id(tenant_id).await?;
.db let repo = 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)
@@ -92,12 +87,8 @@ impl ComplianceAgent {
crate::error::AgentError::Other(format!("Repository {repo_id} not found")) crate::error::AgentError::Other(format!("Repository {repo_id} not found"))
})?; })?;
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 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
+6 -1
View File
@@ -158,11 +158,16 @@ 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.run_scan(&id, ScanTrigger::Manual).await { if let Err(e) = agent_clone
.run_scan(&tenant_id, &id, ScanTrigger::Manual)
.await
{
tracing::error!("Manual scan failed for {id}: {e}"); tracing::error!("Manual scan failed for {id}: {e}");
} }
}); });
+50 -4
View File
@@ -78,19 +78,28 @@ 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> {
let db_name = self.tenant_db_name(&ctx.tenant_id); self.for_tenant_id(&ctx.tenant_id).await
}
/// 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(ctx.tenant_id.clone(), ()).is_none() { if self.ensured.insert(tenant_id.to_string(), ()).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(&ctx.tenant_id); self.ensured.remove(tenant_id);
return Err(e); return Err(e);
} }
tracing::debug!( tracing::debug!(
tenant_id = %ctx.tenant_id, tenant_id = %tenant_id,
db_name = %db_name, db_name = %db_name,
"Indexes ensured for tenant database" "Indexes ensured for tenant database"
); );
@@ -131,6 +140,43 @@ 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.
+4 -7
View File
@@ -25,16 +25,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
tracing::info!("Connecting to MongoDB..."); tracing::info!("Connecting to MongoDB...");
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?; // Per-tenant pool only — the agent has no shared "default" database
db.ensure_indexes().await?; // after M7.2-D. `mongodb_database` is now the db-name prefix used
// 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.clone(), db_pool); let agent = agent::ComplianceAgent::new(config.clone(), db_pool);
tracing::info!("Starting scheduler..."); tracing::info!("Starting scheduler...");
let scheduler_agent = agent.clone(); let scheduler_agent = agent.clone();
+77 -22
View File
@@ -4,8 +4,14 @@ 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
@@ -18,7 +24,9 @@ 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");
scan_all_repos(&agent).await; for tenant_id in scheduler_tenants() {
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}")))?;
@@ -34,7 +42,9 @@ 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");
monitor_cves(&agent).await; for tenant_id in scheduler_tenants() {
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}")))?;
@@ -48,8 +58,9 @@ 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='{}'", "Scheduler started: scans='{}', CVE monitor='{}', tenants={tenants:?}",
agent.config.scan_schedule, agent.config.scan_schedule,
agent.config.cve_monitor_schedule, agent.config.cve_monitor_schedule,
); );
@@ -60,13 +71,47 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
} }
} }
async fn scan_all_repos(agent: &ComplianceAgent) { /// Tenants the scheduler iterates each tick. From `SCHEDULER_TENANT_IDS`
/// (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 cursor = match agent.db.repositories().find(doc! {}).await { let db = match tenant_db(agent, tenant_id).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 scheduled scan: {e}"); tracing::error!("Failed to list repos for tenant '{tenant_id}': {e}");
return; return;
} }
}; };
@@ -75,33 +120,44 @@ async fn scan_all_repos(agent: &ComplianceAgent) {
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.run_scan(&repo_id, ScanTrigger::Scheduled).await { if let Err(e) = agent
tracing::error!("Scheduled scan failed for {}: {e}", repo.name); .run_scan(tenant_id, &repo_id, ScanTrigger::Scheduled)
.await
{
tracing::error!(
"Scheduled scan failed for {} (tenant '{tenant_id}'): {e}",
repo.name
);
} }
} }
} }
async fn monitor_cves(agent: &ComplianceAgent) { async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
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 agent.db.sbom_entries().find(doc! {}).await { let cursor = match 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: {e}"); tracing::error!("CVE monitor: failed to list SBOM entries for '{tenant_id}': {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, skipping"); tracing::debug!("CVE monitor: no SBOM entries for tenant '{tenant_id}', skipping");
return; return;
} }
tracing::info!( tracing::info!(
"CVE monitor: checking {} dependencies for new CVEs", "CVE monitor: checking {} dependencies for new CVEs (tenant '{tenant_id}')",
entries.len() entries.len()
); );
@@ -112,7 +168,7 @@ async fn monitor_cves(agent: &ComplianceAgent) {
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)) = agent.db.repositories().find_one(doc! { "_id": oid }).await { if let Ok(Some(repo)) = db.repositories().find_one(doc! { "_id": oid }).await {
repo_names.insert(rid.clone(), repo.name.clone()); repo_names.insert(rid.clone(), repo.name.clone());
} }
} }
@@ -160,8 +216,7 @@ async fn monitor_cves(agent: &ComplianceAgent) {
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 _ = agent let _ = db
.db
.cve_alerts() .cve_alerts()
.update_one(filter, update) .update_one(filter, update)
.upsert(true) .upsert(true)
@@ -174,8 +229,7 @@ async fn monitor_cves(agent: &ComplianceAgent) {
continue; continue;
} }
if let Some(entry_id) = &entry.id { if let Some(entry_id) = &entry.id {
let _ = agent let _ = db
.db
.sbom_entries() .sbom_entries()
.update_one( .update_one(
doc! { "_id": entry_id }, doc! { "_id": entry_id },
@@ -213,8 +267,7 @@ async fn monitor_cves(agent: &ComplianceAgent) {
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 agent match db
.db
.cve_notifications() .cve_notifications()
.update_one(filter, update) .update_one(filter, update)
.upsert(true) .upsert(true)
@@ -232,8 +285,10 @@ async fn monitor_cves(agent: &ComplianceAgent) {
} }
if new_notifications > 0 { if new_notifications > 0 {
tracing::info!("CVE monitor: created {new_notifications} new notification(s)"); tracing::info!(
"CVE monitor: created {new_notifications} new notification(s) for tenant '{tenant_id}'"
);
} else { } else {
tracing::info!("CVE monitor: no new CVEs found"); tracing::info!("CVE monitor: no new CVEs found for tenant '{tenant_id}'");
} }
} }
+23 -9
View File
@@ -14,24 +14,30 @@ 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(repo_id): Path<String>, Path((tenant_id, repo_id)): Path<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> StatusCode { ) -> StatusCode {
// Look up the repo to get its webhook secret // Look up the repo in the tenant's database 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 repo = match agent let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
.db Ok(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"); tracing::warn!("Gitea webhook: repo {repo_id} not found in tenant '{tenant_id}'");
return StatusCode::NOT_FOUND; return StatusCode::NOT_FOUND;
} }
}; };
@@ -66,15 +72,21 @@ 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!("Gitea push webhook: triggering scan for {repo_id}"); tracing::info!(
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { "Gitea push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
);
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, &repo_id, &payload).await, "pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await,
_ => { _ => {
tracing::debug!("Gitea webhook: ignoring event '{event}'"); tracing::debug!("Gitea webhook: ignoring event '{event}'");
StatusCode::OK StatusCode::OK
@@ -84,6 +96,7 @@ 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 {
@@ -106,13 +119,14 @@ 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(&repo_id, pr_number, &base_sha, &head_sha) .run_pr_review(&tenant_id, &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}");
+23 -9
View File
@@ -14,24 +14,30 @@ 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(repo_id): Path<String>, Path((tenant_id, repo_id)): Path<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> StatusCode { ) -> StatusCode {
// Look up the repo to get its webhook secret // Look up the repo in the tenant's database 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 repo = match agent let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
.db Ok(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"); tracing::warn!("GitHub webhook: repo {repo_id} not found in tenant '{tenant_id}'");
return StatusCode::NOT_FOUND; return StatusCode::NOT_FOUND;
} }
}; };
@@ -66,15 +72,21 @@ 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!("GitHub push webhook: triggering scan for {repo_id}"); tracing::info!(
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { "GitHub push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
);
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, &repo_id, &payload).await, "pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await,
_ => { _ => {
tracing::debug!("GitHub webhook: ignoring event '{event}'"); tracing::debug!("GitHub webhook: ignoring event '{event}'");
StatusCode::OK StatusCode::OK
@@ -84,6 +96,7 @@ 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 {
@@ -105,13 +118,14 @@ 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(&repo_id, pr_number, &base_sha, &head_sha) .run_pr_review(&tenant_id, &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}");
+23 -9
View File
@@ -10,24 +10,30 @@ 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(repo_id): Path<String>, Path((tenant_id, repo_id)): Path<(String, String)>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> StatusCode { ) -> StatusCode {
// Look up the repo to get its webhook secret // Look up the repo in the tenant's database 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 repo = match agent let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
.db Ok(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"); tracing::warn!("GitLab webhook: repo {repo_id} not found in tenant '{tenant_id}'");
return StatusCode::NOT_FOUND; return StatusCode::NOT_FOUND;
} }
}; };
@@ -59,15 +65,21 @@ 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!("GitLab push webhook: triggering scan for {repo_id}"); tracing::info!(
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await { "GitLab push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
);
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, &repo_id, &payload).await, "merge_request" => handle_merge_request(agent, &tenant_id, &repo_id, &payload).await,
_ => { _ => {
tracing::debug!("GitLab webhook: ignoring event '{event_type}'"); tracing::debug!("GitLab webhook: ignoring event '{event_type}'");
StatusCode::OK StatusCode::OK
@@ -77,6 +89,7 @@ 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 {
@@ -101,13 +114,14 @@ 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(&repo_id, mr_iid, &base_sha, &head_sha) .run_pr_review(&tenant_id, &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}");
+8 -4
View File
@@ -9,17 +9,21 @@ 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-repo webhook URLs: /webhook/{platform}/{repo_id} // Per-tenant per-repo webhook URLs: /webhook/{tenant_id}/{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/github/{repo_id}", "/webhook/{tenant_id}/github/{repo_id}",
post(github::handle_github_webhook), post(github::handle_github_webhook),
) )
.route( .route(
"/webhook/gitlab/{repo_id}", "/webhook/{tenant_id}/gitlab/{repo_id}",
post(gitlab::handle_gitlab_webhook), post(gitlab::handle_gitlab_webhook),
) )
.route( .route(
"/webhook/gitea/{repo_id}", "/webhook/{tenant_id}/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())));
+5 -11
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::{Database, DatabasePool}; use compliance_agent::database::DatabasePool;
use compliance_core::AgentConfig; use compliance_core::AgentConfig;
use secrecy::SecretString; use secrecy::SecretString;
@@ -28,11 +28,6 @@ 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");
@@ -73,7 +68,7 @@ impl TestServer {
pentest_imap_password: None, pentest_imap_password: None,
}; };
let agent = ComplianceAgent::new(config, db, db_pool); let agent = ComplianceAgent::new(config, 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
@@ -164,12 +159,11 @@ impl TestServer {
&self.db_name &self.db_name
} }
/// Drop the test database on cleanup. Post-M7.2-B the actual data /// Drop every per-tenant database belonging to this test run.
/// lives in `<db_name>_<tenant>` per-tenant databases; list those /// Post-M7.2-D the agent never opens a `db_name` directly —
/// off the cluster and drop them too. /// data lives only in `<db_name>_<tenant>` per-tenant databases.
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,6 +158,70 @@ 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")
@@ -0,0 +1,210 @@
//! 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));
}
}
+26 -35
View File
@@ -61,23 +61,21 @@ pub async fn send_chat_message(
message: String, message: String,
history: Vec<ChatHistoryMessage>, history: Vec<ChatHistoryMessage>,
) -> Result<ChatApiResponse, ServerFnError> { ) -> Result<ChatApiResponse, ServerFnError> {
let state: super::server_state::ServerState = // Chat uses a longer timeout because the LLM round-trip can be slow;
dioxus_fullstack::FullstackContext::extract().await?; // agent_request doesn't expose a per-call timeout so we layer one on.
let resp = super::agent_client::agent_request(
let url = format!("{}/api/v1/chat/{repo_id}", state.agent_api_url); reqwest::Method::POST,
let client = reqwest::Client::builder() &format!("/api/v1/chat/{repo_id}"),
.timeout(std::time::Duration::from_secs(120)) )
.build() .await?
.map_err(|e| ServerFnError::new(e.to_string()))?; .timeout(std::time::Duration::from_secs(120))
let resp = client .json(&serde_json::json!({
.post(&url) "message": message,
.json(&serde_json::json!({ "history": history,
"message": message, }))
"history": history, .send()
})) .await
.send() .map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?;
.await
.map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?;
let text = resp let text = resp
.text() .text()
@@ -91,19 +89,14 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
&format!("/api/v1/chat/{repo_id}/build-embeddings"),
let url = format!( )
"{}/api/v1/chat/{repo_id}/build-embeddings", .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(())
} }
@@ -111,11 +104,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/chat/{repo_id}/status"))
dioxus_fullstack::FullstackContext::extract().await?; .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
+22 -34
View File
@@ -26,10 +26,9 @@ pub struct DastFindingDetailResponse {
#[server] #[server]
pub async fn fetch_dast_targets() -> Result<DastTargetsResponse, ServerFnError> { pub async fn fetch_dast_targets() -> Result<DastTargetsResponse, ServerFnError> {
let state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/dast/targets")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/dast/targets", state.agent_api_url); .send()
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
@@ -41,10 +40,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/dast/scan-runs")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/dast/scan-runs", state.agent_api_url); .send()
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
@@ -56,10 +54,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/dast/findings")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/dast/findings", state.agent_api_url); .send()
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
@@ -73,10 +70,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/dast/findings/{id}"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/dast/findings/{id}", state.agent_api_url); .send()
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
@@ -88,12 +84,8 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/dast/targets")
dioxus_fullstack::FullstackContext::extract().await?; .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,
@@ -106,17 +98,13 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!( &format!("/api/v1/dast/targets/{target_id}/scan"),
"{}/api/v1/dast/targets/{target_id}/scan", )
state.agent_api_url .await?
); .send()
let client = reqwest::Client::new(); .await
client .map_err(|e| ServerFnError::new(e.to_string()))?;
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -24,39 +24,35 @@ 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 state: super::server_state::ServerState = let mut path = format!("/api/v1/findings?page={}&limit=20", query.page);
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() {
url.push_str(&format!("&severity={}", query.severity)); path.push_str(&format!("&severity={}", query.severity));
} }
if !query.scan_type.is_empty() { if !query.scan_type.is_empty() {
url.push_str(&format!("&scan_type={}", query.scan_type)); path.push_str(&format!("&scan_type={}", query.scan_type));
} }
if !query.status.is_empty() { if !query.status.is_empty() {
url.push_str(&format!("&status={}", query.status)); path.push_str(&format!("&status={}", query.status));
} }
if !query.repo_id.is_empty() { if !query.repo_id.is_empty() {
url.push_str(&format!("&repo_id={}", query.repo_id)); path.push_str(&format!("&repo_id={}", query.repo_id));
} }
if !query.q.is_empty() { if !query.q.is_empty() {
url.push_str(&format!( path.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() {
url.push_str(&format!("&sort_by={}", query.sort_by)); path.push_str(&format!("&sort_by={}", query.sort_by));
} }
if !query.sort_order.is_empty() { if !query.sort_order.is_empty() {
url.push_str(&format!("&sort_order={}", query.sort_order)); path.push_str(&format!("&sort_order={}", query.sort_order));
} }
let resp = reqwest::get(&url) let resp = super::agent_client::agent_get(&path)
.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
@@ -68,11 +64,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/findings/{id}"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/findings/{id}", state.agent_api_url); .send()
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
@@ -86,18 +80,15 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::PATCH,
let url = format!("{}/api/v1/findings/{id}/status", state.agent_api_url); &format!("/api/v1/findings/{id}/status"),
)
let client = reqwest::Client::new(); .await?
client .json(&serde_json::json!({ "status": status }))
.patch(&url) .send()
.json(&serde_json::json!({ "status": status })) .await
.send() .map_err(|e| ServerFnError::new(e.to_string()))?;
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -106,34 +97,25 @@ pub async fn bulk_update_finding_status(
ids: Vec<String>, ids: Vec<String>,
status: String, status: String,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState = super::agent_client::agent_request(reqwest::Method::PATCH, "/api/v1/findings/bulk-status")
dioxus_fullstack::FullstackContext::extract().await?; .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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::PATCH,
let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url); &format!("/api/v1/findings/{id}/feedback"),
)
let client = reqwest::Client::new(); .await?
client .json(&serde_json::json!({ "feedback": feedback }))
.patch(&url) .send()
.json(&serde_json::json!({ "feedback": feedback })) .await
.send() .map_err(|e| ServerFnError::new(e.to_string()))?;
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -50,10 +50,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/graph/{repo_id}"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/graph/{repo_id}", state.agent_api_url); .send()
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
@@ -68,15 +67,12 @@ pub async fn fetch_impact(
repo_id: String, repo_id: String,
finding_id: String, finding_id: String,
) -> Result<ImpactResponse, ServerFnError> { ) -> Result<ImpactResponse, ServerFnError> {
let state: super::server_state::ServerState = let resp =
dioxus_fullstack::FullstackContext::extract().await?; super::agent_client::agent_get(&format!("/api/v1/graph/{repo_id}/impact/{finding_id}"))
let url = format!( .await?
"{}/api/v1/graph/{repo_id}/impact/{finding_id}", .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: ImpactResponse = resp let body: ImpactResponse = resp
.json() .json()
.await .await
@@ -86,10 +82,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/graph/{repo_id}/communities"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/graph/{repo_id}/communities", state.agent_api_url); .send()
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
@@ -104,15 +99,13 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!(
dioxus_fullstack::FullstackContext::extract().await?; "/api/v1/graph/{repo_id}/file-content?path={file_path}"
let url = format!( ))
"{}/api/v1/graph/{repo_id}/file-content?path={file_path}", .await?
state.agent_api_url .send()
); .await
let resp = reqwest::get(&url) .map_err(|e| ServerFnError::new(e.to_string()))?;
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: FileContentResponse = resp let body: FileContentResponse = resp
.json() .json()
.await .await
@@ -122,15 +115,13 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!(
dioxus_fullstack::FullstackContext::extract().await?; "/api/v1/graph/{repo_id}/search?q={query}&limit=50"
let url = format!( ))
"{}/api/v1/graph/{repo_id}/search?q={query}&limit=50", .await?
state.agent_api_url .send()
); .await
let resp = reqwest::get(&url) .map_err(|e| ServerFnError::new(e.to_string()))?;
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: SearchResponse = resp let body: SearchResponse = resp
.json() .json()
.await .await
@@ -140,14 +131,13 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!("{}/api/v1/graph/{repo_id}/build", state.agent_api_url); &format!("/api/v1/graph/{repo_id}/build"),
let client = reqwest::Client::new(); )
client .await?
.post(&url) .send()
.send() .await
.await .map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -12,11 +12,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/issues?page={page}&limit=20"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/issues?page={page}&limit=20", state.agent_api_url); .send()
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,6 +18,8 @@ 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,11 +32,9 @@ pub struct NotificationCountResponse {
#[server] #[server]
pub async fn fetch_notification_count() -> Result<u64, ServerFnError> { pub async fn fetch_notification_count() -> Result<u64, ServerFnError> {
let state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/notifications/count")
dioxus_fullstack::FullstackContext::extract().await?; .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
@@ -48,11 +46,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/notifications?limit=20")
dioxus_fullstack::FullstackContext::extract().await?; .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
@@ -64,12 +60,8 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/notifications/read-all")
dioxus_fullstack::FullstackContext::extract().await?; .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()))?;
@@ -78,14 +70,13 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::PATCH,
&format!("/api/v1/notifications/{id}/dismiss"),
let url = format!("{}/api/v1/notifications/{id}/dismiss", state.agent_api_url); )
reqwest::Client::new() .await?
.patch(&url) .send()
.send() .await
.await .map_err(|e| ServerFnError::new(e.to_string()))?;
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(()) Ok(())
} }
+145 -184
View File
@@ -32,12 +32,10 @@ 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 url = format!("{}/api/v1/pentest/sessions", state.agent_api_url); let resp = super::agent_client::agent_get("/api/v1/pentest/sessions")
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 mut body: PentestSessionsResponse = resp let mut body: PentestSessionsResponse = resp
@@ -46,31 +44,32 @@ 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
let targets_url = format!("{}/api/v1/dast/targets", state.agent_api_url); if let Ok(tresp_builder) = super::agent_client::agent_get("/api/v1/dast/targets").await {
if let Ok(tresp) = reqwest::get(&targets_url).await { if let Ok(tresp) = tresp_builder.send().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()),
) )
}); });
}
} }
} }
} }
@@ -83,10 +82,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/pentest/sessions/{id}"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/pentest/sessions/{id}", state.agent_api_url); .send()
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
@@ -96,26 +94,27 @@ 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()) {
let targets_url = format!("{}/api/v1/dast/targets", state.agent_api_url); if let Ok(tresp_builder) = super::agent_client::agent_get("/api/v1/dast/targets").await {
if let Ok(tresp) = reqwest::get(&targets_url).await { if let Ok(tresp) = tresp_builder.send().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;
} }
} }
} }
@@ -130,15 +129,12 @@ 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 state: super::server_state::ServerState = let resp =
dioxus_fullstack::FullstackContext::extract().await?; super::agent_client::agent_get(&format!("/api/v1/pentest/sessions/{session_id}/messages"))
let url = format!( .await?
"{}/api/v1/pentest/sessions/{session_id}/messages", .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: PentestMessagesResponse = resp let body: PentestMessagesResponse = resp
.json() .json()
.await .await
@@ -148,10 +144,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/pentest/stats")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/pentest/stats", state.agent_api_url); .send()
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
@@ -163,15 +158,13 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!(
dioxus_fullstack::FullstackContext::extract().await?; "/api/v1/pentest/sessions/{session_id}/attack-chain"
let url = format!( ))
"{}/api/v1/pentest/sessions/{session_id}/attack-chain", .await?
state.agent_api_url .send()
); .await
let resp = reqwest::get(&url) .map_err(|e| ServerFnError::new(e.to_string()))?;
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: AttackChainResponse = resp let body: AttackChainResponse = resp
.json() .json()
.await .await
@@ -185,20 +178,17 @@ pub async fn create_pentest_session(
strategy: String, strategy: String,
message: String, message: String,
) -> Result<PentestSessionResponse, ServerFnError> { ) -> Result<PentestSessionResponse, ServerFnError> {
let state: super::server_state::ServerState = let resp =
dioxus_fullstack::FullstackContext::extract().await?; super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/pentest/sessions")
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url); .await?
let client = reqwest::Client::new(); .json(&serde_json::json!({
let resp = client "target_id": target_id,
.post(&url) "strategy": strategy,
.json(&serde_json::json!({ "message": message,
"target_id": target_id, }))
"strategy": strategy, .send()
"message": message, .await
})) .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
@@ -211,18 +201,15 @@ 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 client = reqwest::Client::new(); let resp =
let resp = client super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/pentest/sessions")
.post(&url) .await?
.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!(
@@ -239,8 +226,6 @@ 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| {
@@ -251,13 +236,12 @@ pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, Server
} }
}) })
.collect(); .collect();
let api_url = format!( let resp =
"{}/api/v1/pentest/lookup-repo?url={}", super::agent_client::agent_get(&format!("/api/v1/pentest/lookup-repo?url={encoded_url}"))
state.agent_api_url, encoded_url .await?
); .send()
let resp = reqwest::get(&api_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
.json() .json()
.await .await
@@ -270,21 +254,17 @@ pub async fn send_pentest_message(
session_id: String, session_id: String,
message: String, message: String,
) -> Result<PentestMessagesResponse, ServerFnError> { ) -> Result<PentestMessagesResponse, ServerFnError> {
let state: super::server_state::ServerState = let resp = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!( &format!("/api/v1/pentest/sessions/{session_id}/chat"),
"{}/api/v1/pentest/sessions/{session_id}/chat", )
state.agent_api_url .await?
); .json(&serde_json::json!({
let client = reqwest::Client::new(); "message": message,
let resp = client }))
.post(&url) .send()
.json(&serde_json::json!({ .await
"message": message, .map_err(|e| ServerFnError::new(e.to_string()))?;
}))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: PentestMessagesResponse = resp let body: PentestMessagesResponse = resp
.json() .json()
.await .await
@@ -294,35 +274,27 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!( &format!("/api/v1/pentest/sessions/{session_id}/stop"),
"{}/api/v1/pentest/sessions/{session_id}/stop", )
state.agent_api_url .await?
); .send()
let client = reqwest::Client::new(); .await
client .map_err(|e| ServerFnError::new(e.to_string()))?;
.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 state: super::server_state::ServerState = let resp = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!( &format!("/api/v1/pentest/sessions/{session_id}/pause"),
"{}/api/v1/pentest/sessions/{session_id}/pause", )
state.agent_api_url .await?
); .send()
let client = reqwest::Client::new(); .await
let resp = client .map_err(|e| ServerFnError::new(e.to_string()))?;
.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}")));
@@ -332,18 +304,14 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!( &format!("/api/v1/pentest/sessions/{session_id}/resume"),
"{}/api/v1/pentest/sessions/{session_id}/resume", )
state.agent_api_url .await?
); .send()
let client = reqwest::Client::new(); .await
let resp = client .map_err(|e| ServerFnError::new(e.to_string()))?;
.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}")));
@@ -355,15 +323,12 @@ 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 state: super::server_state::ServerState = let resp =
dioxus_fullstack::FullstackContext::extract().await?; super::agent_client::agent_get(&format!("/api/v1/pentest/sessions/{session_id}/findings"))
let url = format!( .await?
"{}/api/v1/pentest/sessions/{session_id}/findings", .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: DastFindingsResponse = resp let body: DastFindingsResponse = resp
.json() .json()
.await .await
@@ -385,23 +350,19 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!( &format!("/api/v1/pentest/sessions/{session_id}/export"),
"{}/api/v1/pentest/sessions/{session_id}/export", )
state.agent_api_url .await?
); .json(&serde_json::json!({
let client = reqwest::Client::new(); "password": password,
let resp = client "requester_name": requester_name,
.post(&url) "requester_email": requester_email,
.json(&serde_json::json!({ }))
"password": password, .send()
"requester_name": requester_name, .await
"requester_email": requester_email, .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 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,14 +12,10 @@ 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 state: super::server_state::ServerState = let path = format!("/api/v1/repositories?page={page}&limit=20");
dioxus_fullstack::FullstackContext::extract().await?; let resp = super::agent_client::agent_get(&path)
let url = format!( .await?
"{}/api/v1/repositories?page={page}&limit=20", .send()
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
@@ -41,10 +37,6 @@ 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,
@@ -69,9 +61,8 @@ pub async fn add_repository(
body["tracker_token"] = serde_json::Value::String(tk); body["tracker_token"] = serde_json::Value::String(tk);
} }
let client = reqwest::Client::new(); let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/repositories")
let resp = client .await?
.post(&url)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -100,10 +91,6 @@ 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));
@@ -133,13 +120,15 @@ 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 client = reqwest::Client::new(); let resp = super::agent_client::agent_request(
let resp = client reqwest::Method::PATCH,
.patch(&url) &format!("/api/v1/repositories/{repo_id}"),
.json(&body) )
.send() .await?
.await .json(&body)
.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 text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
@@ -153,11 +142,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/settings/ssh-public-key")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url); .send()
let resp = reqwest::get(&url)
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
@@ -179,16 +166,14 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::DELETE,
let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url); &format!("/api/v1/repositories/{repo_id}"),
)
let client = reqwest::Client::new(); .await?
let resp = client .send()
.delete(&url) .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 body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
@@ -202,16 +187,14 @@ 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> {
let state: super::server_state::ServerState = super::agent_client::agent_request(
dioxus_fullstack::FullstackContext::extract().await?; reqwest::Method::POST,
let url = format!("{}/api/v1/repositories/{repo_id}/scan", state.agent_api_url); &format!("/api/v1/repositories/{repo_id}/scan"),
)
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(())
} }
@@ -224,16 +207,12 @@ 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 state: super::server_state::ServerState = let resp =
dioxus_fullstack::FullstackContext::extract().await?; super::agent_client::agent_get(&format!("/api/v1/repositories/{repo_id}/webhook-config"))
let url = format!( .await?
"{}/api/v1/repositories/{repo_id}/webhook-config", .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: WebhookConfigResponse = resp let body: WebhookConfigResponse = resp
.json() .json()
.await .await
@@ -244,11 +223,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/scan-runs?page=1&limit=1")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/scan-runs?page=1&limit=1", state.agent_api_url); .send()
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
+20 -35
View File
@@ -87,11 +87,9 @@ pub struct SbomFiltersResponse {
#[server] #[server]
pub async fn fetch_sbom_filters() -> Result<SbomFiltersResponse, ServerFnError> { pub async fn fetch_sbom_filters() -> Result<SbomFiltersResponse, ServerFnError> {
let state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/sbom/filters")
dioxus_fullstack::FullstackContext::extract().await?; .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
@@ -112,9 +110,6 @@ 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() {
@@ -140,9 +135,10 @@ pub async fn fetch_sbom_filtered(
} }
} }
let url = format!("{}/api/v1/sbom?{}", state.agent_api_url, params.join("&")); let path = format!("/api/v1/sbom?{}", params.join("&"));
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
@@ -156,15 +152,10 @@ 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 state: super::server_state::ServerState = let path = format!("/api/v1/sbom/export?repo_id={repo_id}&format={format}");
dioxus_fullstack::FullstackContext::extract().await?; let resp = super::agent_client::agent_get(&path)
.await?
let url = format!( .send()
"{}/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
@@ -178,17 +169,16 @@ 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 state: super::server_state::ServerState = let mut path = "/api/v1/sbom/licenses".to_string();
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() {
url = format!("{url}?repo_id={r}"); path = format!("{path}?repo_id={r}");
} }
} }
let resp = reqwest::get(&url) let resp = super::agent_client::agent_get(&path)
.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
@@ -205,15 +195,10 @@ 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 state: super::server_state::ServerState = let path = format!("/api/v1/sbom/diff?repo_a={repo_a}&repo_b={repo_b}");
dioxus_fullstack::FullstackContext::extract().await?; let resp = super::agent_client::agent_get(&path)
.await?
let url = format!( .send()
"{}/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,14 +12,9 @@ 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 state: super::server_state::ServerState = let resp = super::agent_client::agent_get(&format!("/api/v1/scan-runs?page={page}&limit=20"))
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!( .send()
"{}/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,11 +16,9 @@ pub struct OverviewStats {
#[server] #[server]
pub async fn fetch_overview_stats() -> Result<OverviewStats, ServerFnError> { pub async fn fetch_overview_stats() -> Result<OverviewStats, ServerFnError> {
let state: super::server_state::ServerState = let resp = super::agent_client::agent_get("/api/v1/stats/overview")
dioxus_fullstack::FullstackContext::extract().await?; .await?
let url = format!("{}/api/v1/stats/overview", state.agent_api_url); .send()
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