diff --git a/crates/gitlawb-node/src/api/agents.rs b/crates/gitlawb-node/src/api/agents.rs index dea088a..edeb0e1 100644 --- a/crates/gitlawb-node/src/api/agents.rs +++ b/crates/gitlawb-node/src/api/agents.rs @@ -2,9 +2,10 @@ use axum::extract::{Path, Query, State}; use axum::http::StatusCode; -use axum::Json; +use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; +use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; use crate::state::AppState; @@ -20,6 +21,14 @@ fn agent_key_segment(did: &str) -> &str { did.split(':').next_back().unwrap_or(did) } +/// Whether `caller` (the verified `AuthenticatedDid`) is the same identity as +/// `target` (the DID being acted on), tolerant of full `did:key:...` vs short +/// key-only forms on either side. Used to gate self-deregistration so a DID +/// can only retire itself. +fn caller_matches_did(caller: &str, target: &str) -> bool { + agent_key_segment(caller) == agent_key_segment(target) +} + async fn resolve_agent_did(state: &AppState, did: &str) -> Result { let normalized_did = normalize_agent_did(did); if state.db.get_agent(&normalized_did).await?.is_some() { @@ -71,6 +80,8 @@ pub struct AgentResponse { pub capabilities: Vec, pub registered_at: String, pub last_seen: Option, + /// Lifecycle status: `active` or `revoked`. + pub status: String, } /// GET /api/v1/agents @@ -87,6 +98,7 @@ pub async fn list_agents( capabilities: a.capabilities, registered_at: a.registered_at, last_seen: a.last_seen, + status: a.status, }) .collect(); Ok(Json(serde_json::json!({ "agents": list }))) @@ -111,6 +123,7 @@ pub async fn show_agent( capabilities: agent.capabilities, registered_at: agent.registered_at, last_seen: agent.last_seen, + status: agent.status, }), )) } @@ -133,9 +146,97 @@ pub async fn get_trust( })) } +/// DELETE /api/v1/agents/{did} +/// +/// Self-deregistration: the holder of a DID's key marks their own agent +/// `revoked`, removing it from discovery (issue #29). Authenticated by the +/// rfc9421 signature middleware; a caller may only retire its own DID. +pub async fn deregister_agent( + State(state): State, + Extension(auth): Extension, + Path(did): Path, +) -> Result { + // Authorize on the verified identity, and act on it too. The path DID is + // only an intent check (it must name the caller); the row we revoke is the + // authenticated DID itself, never a value derived from the untrusted path. + // Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix + // resolver act on a different identity than the one just authorized. + if !caller_matches_did(&auth.0, &did) { + return Err(AppError::BadRequest( + "an agent can only deregister itself".into(), + )); + } + + if state.db.revoke_agent(&auth.0).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound(format!("agent {} not found", auth.0))) + } +} + #[cfg(test)] mod tests { - use super::{agent_key_segment, normalize_agent_did}; + use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse}; + + #[test] + fn caller_matches_own_did_full_form() { + assert!(caller_matches_did( + "did:key:z6MkExample", + "did:key:z6MkExample" + )); + } + + #[test] + fn caller_matches_own_did_short_form() { + // Authenticated full DID vs short path form, and vice versa. + assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample")); + assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample")); + } + + #[test] + fn caller_cannot_revoke_a_different_did() { + // The core authorization property for issue #29. + assert!(!caller_matches_did( + "did:key:z6MkAttacker", + "did:key:z6MkVictim" + )); + assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim")); + } + + #[test] + fn agent_response_surfaces_status() { + // A revoked DID's response must carry its status so callers can see it + // is retired (issue #29). + let resp = AgentResponse { + did: "did:key:orphan".to_string(), + trust_score: 0.1, + capabilities: vec!["reputation:score".to_string()], + registered_at: "2026-06-19T00:00:00Z".to_string(), + last_seen: None, + status: "revoked".to_string(), + }; + + let json = serde_json::to_value(&resp).unwrap(); + + assert_eq!(json["status"], "revoked"); + assert!(json.get("replaced_by").is_none()); + } + + #[test] + fn agent_response_active_status() { + let resp = AgentResponse { + did: "did:key:active".to_string(), + trust_score: 0.5, + capabilities: vec![], + registered_at: "2026-06-19T00:00:00Z".to_string(), + last_seen: None, + status: "active".to_string(), + }; + + let json = serde_json::to_value(&resp).unwrap(); + + assert_eq!(json["status"], "active"); + } #[test] fn normalize_agent_did_preserves_full_did() { diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 4a1c107..3d1192d 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -221,6 +221,8 @@ pub struct AgentRow { pub capabilities: Vec, pub registered_at: String, pub last_seen: Option, + /// Lifecycle status: `active` (default) or `revoked` (self-deregistered). + pub status: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -735,6 +737,16 @@ const MIGRATIONS: &[Migration] = &[ "CREATE INDEX IF NOT EXISTS idx_encrypted_blobs_repo ON encrypted_blobs(repo_id)", ], }, + Migration { + version: 5, + name: "agent_retirement", + stmts: &[ + // Agent lifecycle status for issue #29. `active` is the default; + // the key holder can self-deregister to `revoked` (terminal). + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'", + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS deactivated_at TEXT", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -941,10 +953,41 @@ impl Db { // ── Agents / Trust ──────────────────────────────────────────────────────────── +/// Map an `agents` row (selected with the status columns) into an `AgentRow`. +fn row_to_agent(r: &sqlx::postgres::PgRow) -> AgentRow { + AgentRow { + did: r.get("did"), + trust_score: r.get("trust_score"), + capabilities: serde_json::from_str(r.get::<&str, _>("capabilities")).unwrap_or_default(), + registered_at: r.get("registered_at"), + last_seen: r.get("last_seen"), + status: r.get("status"), + } +} + +/// Reduce a trust-ranked agent list to what discovery should surface: only +/// `active` agents, optionally narrowed to those advertising `capability`. +/// Revoked agents are dropped so an orphaned DID can never win capability +/// routing. Input order is preserved, so an already trust-sorted list stays +/// active-first. +fn filter_discoverable(agents: Vec, capability: Option<&str>) -> Vec { + agents + .into_iter() + .filter(|a| a.status == "active") + .filter(|a| match capability { + Some(cap) => a.capabilities.iter().any(|c| c == cap), + None => true, + }) + .collect() +} + impl Db { pub async fn register_agent(&self, did: &str, capabilities: &[String]) -> Result<()> { let caps = serde_json::to_string(capabilities)?; let now = Utc::now().to_rfc3339(); + // The ON CONFLICT clause deliberately updates only `last_seen` and + // never touches `status`. That makes revocation terminal: re-registering + // a `revoked` DID does not bring it back to `active` (issue #29). sqlx::query( "INSERT INTO agents (did, trust_score, capabilities, registered_at) VALUES ($1, 0.0, $2, $3) @@ -1021,46 +1064,46 @@ impl Db { pub async fn list_agents(&self, capability: Option<&str>) -> Result> { let rows = sqlx::query( - "SELECT did, trust_score, capabilities, registered_at, last_seen FROM agents ORDER BY trust_score DESC", + "SELECT did, trust_score, capabilities, registered_at, last_seen, status \ + FROM agents ORDER BY trust_score DESC", ) .fetch_all(&self.pool) .await?; - let mut agents: Vec = rows - .iter() - .map(|r| AgentRow { - did: r.get("did"), - trust_score: r.get("trust_score"), - capabilities: serde_json::from_str(r.get::<&str, _>("capabilities")) - .unwrap_or_default(), - registered_at: r.get("registered_at"), - last_seen: r.get("last_seen"), - }) - .collect(); - - if let Some(cap) = capability { - agents.retain(|a| a.capabilities.iter().any(|c| c == cap)); - } + let agents: Vec = rows.iter().map(row_to_agent).collect(); - Ok(agents) + Ok(filter_discoverable(agents, capability)) } pub async fn get_agent(&self, did: &str) -> Result> { let row = sqlx::query( - "SELECT did, trust_score, capabilities, registered_at, last_seen FROM agents WHERE did = $1", + "SELECT did, trust_score, capabilities, registered_at, last_seen, status \ + FROM agents WHERE did = $1", ) .bind(did) .fetch_optional(&self.pool) .await?; - Ok(row.map(|r| AgentRow { - did: r.get("did"), - trust_score: r.get("trust_score"), - capabilities: serde_json::from_str(r.get::<&str, _>("capabilities")) - .unwrap_or_default(), - registered_at: r.get("registered_at"), - last_seen: r.get("last_seen"), - })) + // Unfiltered by design: a revoked DID must still resolve so callers + // can read its `status` and see it is retired. + Ok(row.as_ref().map(row_to_agent)) + } + + /// Mark an agent `revoked` (terminal self-deregistration, issue #29). + /// Returns `false` when no such agent exists so the caller can surface a + /// 404. Revoking an already-revoked agent is idempotent, and a retry keeps + /// the original `deactivated_at` (COALESCE) rather than overwriting it. + pub async fn revoke_agent(&self, did: &str) -> Result { + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE agents SET status = 'revoked', \ + deactivated_at = COALESCE(deactivated_at, $2) WHERE did = $1", + ) + .bind(did) + .bind(&now) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) } pub async fn count_pushes(&self) -> Result { @@ -2876,3 +2919,91 @@ mod migration_tests { assert_eq!(MIGRATIONS[0].name, MIGRATION_V1_NAME); } } + +#[cfg(test)] +mod agent_discovery_tests { + use super::{filter_discoverable, AgentRow}; + + fn agent(did: &str, trust: f64, status: &str, caps: &[&str]) -> AgentRow { + AgentRow { + did: did.to_string(), + trust_score: trust, + capabilities: caps.iter().map(|c| c.to_string()).collect(), + registered_at: "2026-06-19T00:00:00Z".to_string(), + last_seen: None, + status: status.to_string(), + } + } + + fn dids(rows: &[AgentRow]) -> Vec<&str> { + rows.iter().map(|a| a.did.as_str()).collect() + } + + #[test] + fn only_active_agents_are_returned() { + let rows = vec![ + agent("did:key:active1", 0.5, "active", &["reputation:score"]), + agent("did:key:revoked1", 0.4, "revoked", &["reputation:score"]), + agent("did:key:revoked2", 0.3, "revoked", &["reputation:score"]), + ]; + + let out = filter_discoverable(rows, None); + + assert_eq!(dids(&out), vec!["did:key:active1"]); + } + + #[test] + fn revoked_orphan_never_wins_capability_routing() { + // Reproduces issue #29: a self-deregistered orphan sharing the + // canonical agent's capability and equal trust must be excluded so the + // active replacement is the only capability match. + let rows = vec![ + agent("did:key:orphan", 0.1, "revoked", &["reputation:score"]), + agent("did:key:canonical", 0.1, "active", &["reputation:score"]), + ]; + + let out = filter_discoverable(rows, Some("reputation:score")); + + assert_eq!(dids(&out), vec!["did:key:canonical"]); + } + + #[test] + fn capability_and_status_filters_compose() { + let rows = vec![ + // matches capability but retired -> excluded + agent("did:key:revoked", 0.9, "revoked", &["attestation:verify"]), + // active but wrong capability -> excluded + agent("did:key:other", 0.8, "active", &["oracle:agent-trust"]), + // active and matches -> kept + agent("did:key:match", 0.7, "active", &["attestation:verify"]), + ]; + + let out = filter_discoverable(rows, Some("attestation:verify")); + + assert_eq!(dids(&out), vec!["did:key:match"]); + } + + #[test] + fn input_order_is_preserved_so_active_stays_trust_ranked() { + // Input arrives pre-sorted by trust desc; filtering must not reorder. + let rows = vec![ + agent("did:key:high", 0.9, "active", &[]), + agent("did:key:retired", 0.8, "revoked", &[]), + agent("did:key:mid", 0.5, "active", &[]), + agent("did:key:low", 0.2, "active", &[]), + ]; + + let out = filter_discoverable(rows, None); + + assert_eq!( + dids(&out), + vec!["did:key:high", "did:key:mid", "did:key:low"] + ); + } + + #[test] + fn empty_input_returns_empty() { + assert!(filter_discoverable(vec![], None).is_empty()); + assert!(filter_discoverable(vec![], Some("reputation:score")).is_empty()); + } +} diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 3261bdb..27758da 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -147,6 +147,10 @@ pub fn build_router(state: AppState) -> Router { axum::routing::put(visibility::set_visibility) .delete(visibility::remove_visibility) .get(visibility::list_visibility), + ) + .route( + "/api/v1/agents/{did}", + axum::routing::delete(agents::deregister_agent), ), state.clone(), );