inbox.rs
616 lignes · 22108 octets
//! `POST /inbox` handler — receives ActivityPub lifecycle activities //! and dispatches them to [`crate::services::fediverse_signal_service::FediverseSignalService`] //! (RFC 0012). //! //! The route reads the `Signature` HTTP header, delegates verification //! to the configured `SignatureVerifier`, then routes by activity //! `type`. Only `Update`, `Delete`, and `Revoke` are supported in this //! slice; the rest get `501 Not Implemented`. //! use std::sync::Arc; use axum::{Extension, body::Bytes, http::HeaderMap, http::StatusCode}; use sea_orm::DatabaseConnection; use serde::Deserialize; use uuid::Uuid; use super::error::FediverseError; use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; use crate::services::fediverse_signal_service::{ DeleteActorHooks, FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, }; /// Per-router state injected into the inbox handler. /// /// Cheaply clonable (the verifier is `Arc`-ed). The application /// instantiates this once via the federation router builder. #[derive(Clone)] pub struct InboxState { /// Pluggable signature verifier. Defaults to `AlwaysAcceptVerifier` /// when the consumer does not call /// `RustwardenBuilder::with_signature_verifier`. pub verifier: Arc<dyn SignatureVerifier>, } /// Minimal projection of the JSON-LD payload. /// /// We only deserialise the fields the dispatcher needs; the full body /// is forwarded as `serde_json::Value` to the service for audit / /// re-processing. #[derive(Debug, Deserialize)] struct InboxActivity { #[serde(rename = "type")] kind: String, actor: Option<String>, object: Option<serde_json::Value>, } /// `POST /inbox` axum handler. pub async fn handle( Extension(state): Extension<InboxState>, Extension(db): Extension<DatabaseConnection>, observer: Option<Extension<Arc<dyn FediverseEventObserver>>>, headers: HeaderMap, body: Bytes, ) -> Result<StatusCode, FediverseError> { let observer: Arc<dyn FediverseEventObserver> = match observer { Some(Extension(obs)) => obs, None => Arc::new(NoopFediverseObserver), }; // 1. Signature verification (delegated). let signature_header = headers .get("signature") .and_then(|v| v.to_str().ok()) .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".to_string()))?; let activity: InboxActivity = match serde_json::from_slice(&body) { Ok(a) => a, Err(e) => { // RFC 0028 — emit a structural-abuse event so detectors can // count repeated malformed bodies from the same source. The // domain extraction is best-effort: a malformed body usually // means we can't even read the actor URL, so we fall back to // the empty string which the observer-side normaliser // suppresses. observer.on_inbox_abuse("", None, "malformed_json").await; return Err(FediverseError::BadRequest(format!("invalid JSON: {e}"))); } }; let actor_ap_id = activity.actor.as_deref().unwrap_or(""); // Touch `signature_header` to keep the early validation in place // (its absence already errors above) — the value is also reachable // via `headers["signature"]` for impls. let _ = signature_header; // F8 / RFC 0027 — trust policy gate. Reject before any verifier work // or downstream effect when the source domain isn't allowed. let source_domain = url::Url::parse(actor_ap_id) .ok() .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) .assert_domain_allowed(&source_domain) .await .map_err(|e| FediverseError::Forbidden(e.to_string()))?; state .verifier .verify(&VerifyCtx { actor_ap_id, method: "post", path: "/inbox", headers: &headers, body: &body, public_key_pem: "", }) .await .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; // 2. Dispatch by activity kind. match activity.kind.as_str() { "Update" => dispatch_update(&db, &activity).await?, "Delete" => dispatch_delete(&db, &activity, observer.as_ref()).await?, "Revoke" => dispatch_revoke(&db, &activity).await?, other => { observer .on_inbox_abuse(&source_domain, Some(actor_ap_id), "unknown_activity_type") .await; return Err(FediverseError::Unsupported(other.to_string())); } } Ok(StatusCode::ACCEPTED) } async fn dispatch_update( db: &DatabaseConnection, activity: &InboxActivity, ) -> Result<(), FediverseError> { let object = activity .object .as_ref() .ok_or_else(|| FediverseError::BadRequest("Update missing `object`".into()))?; let ap_id = object .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| FediverseError::BadRequest("Update.object missing `id`".into()))? .to_string(); let display_name = object .get("name") .and_then(|v| v.as_str()) .map(str::to_string); let summary_html = object .get("summary") .and_then(|v| v.as_str()) .map(str::to_string); let icon_url = object .get("icon") .and_then(|v| v.get("url")) .and_then(|v| v.as_str()) .map(str::to_string); let public_key_pem = object .get("publicKey") .and_then(|v| v.get("publicKeyPem")) .and_then(|v| v.as_str()) .map(str::to_string); FediverseSignalService::apply_update_actor( db, UpdatedActor { ap_id, display_name, summary_html, icon_url, public_key_pem, raw_json: Some(object.clone()), }, ) .await?; Ok(()) } async fn dispatch_delete( db: &DatabaseConnection, activity: &InboxActivity, observer: &dyn FediverseEventObserver, ) -> Result<(), FediverseError> { // AP `Delete{Actor}` carries the actor URL either as a string in // `object` or as `{ "id": "...", "type": "Person" }`. let ap_id = activity .object .as_ref() .and_then(|o| { o.as_str() .map(str::to_string) .or_else(|| o.get("id").and_then(|v| v.as_str()).map(str::to_string)) }) .ok_or_else(|| FediverseError::BadRequest("Delete missing actor reference".into()))?; FediverseSignalService::apply_delete_actor_with_hooks( db, &ap_id, DeleteActorHooks { observer: Some(observer), ..Default::default() }, ) .await?; Ok(()) } async fn dispatch_revoke( db: &DatabaseConnection, activity: &InboxActivity, ) -> Result<(), FediverseError> { let object = activity .object .as_ref() .ok_or_else(|| FediverseError::BadRequest("Revoke missing `object`".into()))?; let team_id = object .get("team") .and_then(|v| v.as_str()) .and_then(|s| s.rsplit('/').next()) .and_then(|s| Uuid::parse_str(s).ok()) .ok_or_else(|| FediverseError::BadRequest("Revoke.object.team missing/malformed".into()))?; let remote_identity_id = object .get("subject") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) .ok_or_else(|| { FediverseError::BadRequest("Revoke.object.subject missing/malformed".into()) })?; FediverseSignalService::apply_revoke_membership(db, team_id, remote_identity_id).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use axum::{Router, body::Body, http::Request, routing::post}; use rustwarden_core::error::AppError; use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; use tower::ServiceExt; fn make_router(verifier: Arc<dyn SignatureVerifier>, db: DatabaseConnection) -> Router { Router::new() .route("/inbox", post(handle)) .layer(Extension(InboxState { verifier })) .layer(Extension(db)) } fn signed_request(body: &str) -> Request<Body> { Request::builder() .uri("/inbox") .method("POST") .header("signature", "keyId=\"test\",signature=\"...\"") .header("content-type", "application/activity+json") .body(Body::from(body.to_string())) .unwrap() } struct AlwaysAccept; #[async_trait] impl SignatureVerifier for AlwaysAccept { async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { Ok(()) } } struct AlwaysReject; #[async_trait] impl SignatureVerifier for AlwaysReject { async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { Err(AppError::Internal("nope".into())) } } /// F8 enforcement helper: seed three mock results so that /// `FediverseTrustService::assert_domain_allowed` answers Ok with /// no real DB. Mode is `open_with_blocklist` + empty blocklist, the /// cheapest permissive shape for unit tests. fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { use crate::models::fediverse_blocked_domain; use rustwarden_core::models::app_settings; let now = chrono::Utc::now(); mock.append_query_results([vec![app_settings::Model { id: Uuid::new_v4(), key: "fediverse_enabled".to_string(), value: "true".to_string(), description: None, updated_by: None, updated_at: now, }]]) .append_query_results([vec![app_settings::Model { id: Uuid::new_v4(), key: "fediverse_trust_mode".to_string(), value: "open_with_blocklist".to_string(), description: None, updated_by: None, updated_at: now, }]]) .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) } fn db_for_update() -> DatabaseConnection { let now = chrono::Utc::now(); let row = crate::models::remote_identity::Model { id: Uuid::new_v4(), ap_id: "https://a.tld/users/alice".into(), username: "alice".into(), domain: "a.tld".into(), display_name: None, summary_html: None, inbox_url: "https://a.tld/users/alice/inbox".into(), shared_inbox_url: None, public_key_pem: String::new(), icon_url: None, actor_type: "Person".into(), raw_json: None, last_fetched_at: now, disabled_at: None, created_at: now, updated_at: now, }; let mut after = row.clone(); after.display_name = Some("Alice".into()); permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection() } fn db_for_delete_noop() -> DatabaseConnection { // SELECT returns empty → DeleteOutcome::default(), no UPDATE. permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) .into_connection() } fn db_for_revoke_noop() -> DatabaseConnection { permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 0, }]) .into_connection() } #[tokio::test] async fn inbox_returns_202_for_valid_update_actor() { let body = serde_json::json!({ "type": "Update", "actor": "https://a.tld/users/alice", "object": { "id": "https://a.tld/users/alice", "type": "Person", "name": "Alice", } }) .to_string(); let router = make_router(Arc::new(AlwaysAccept), db_for_update()); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::ACCEPTED); } #[tokio::test] async fn inbox_returns_202_for_valid_delete_actor() { let body = serde_json::json!({ "type": "Delete", "actor": "https://a.tld/users/alice", "object": "https://a.tld/users/alice" }) .to_string(); let router = make_router(Arc::new(AlwaysAccept), db_for_delete_noop()); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::ACCEPTED); } #[tokio::test] async fn inbox_returns_202_for_valid_revoke_membership() { let team_id = Uuid::new_v4(); let remote_id = Uuid::new_v4(); let body = serde_json::json!({ "type": "Revoke", "actor": "https://a.tld/users/alice", "object": { "type": "TeamMembership", "team": format!("https://b.tld/teams/{team_id}"), "subject": remote_id.to_string(), } }) .to_string(); let router = make_router(Arc::new(AlwaysAccept), db_for_revoke_noop()); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::ACCEPTED); } #[tokio::test] async fn inbox_returns_401_when_signature_header_missing() { let body = "{\"type\":\"Update\"}"; let req = Request::builder() .uri("/inbox") .method("POST") .body(Body::from(body)) .unwrap(); let router = make_router( Arc::new(AlwaysAccept), MockDatabase::new(DatabaseBackend::Postgres).into_connection(), ); let resp = router.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn inbox_returns_401_when_verifier_rejects() { let body = serde_json::json!({ "type": "Update", "actor": "https://a.tld/users/alice" }) .to_string(); let router = make_router( Arc::new(AlwaysReject), permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), ); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn inbox_returns_400_for_invalid_json() { let router = make_router( Arc::new(AlwaysAccept), MockDatabase::new(DatabaseBackend::Postgres).into_connection(), ); let resp = router.oneshot(signed_request("not json")).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn inbox_returns_501_for_unknown_activity_type() { let body = serde_json::json!({ "type": "Like", "actor": "https://a.tld/users/alice" }) .to_string(); let router = make_router( Arc::new(AlwaysAccept), permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), ); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); } #[tokio::test] async fn inbox_smoke_test_with_always_accept_verifier() { use crate::services::fediverse_signal_service::AlwaysAcceptVerifier; let body = serde_json::json!({ "type": "Delete", "actor": "https://a.tld/users/ghost", "object": "https://a.tld/users/ghost" }) .to_string(); let router = make_router(Arc::new(AlwaysAcceptVerifier::new()), db_for_delete_noop()); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::ACCEPTED); } // ─── RFC 0028 / Lot 7b — observer emission points ────────────── use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(Default)] struct CountingObserver { reasons: std::sync::Mutex<Vec<String>>, calls: AtomicUsize, delete_calls: std::sync::Mutex<Vec<(String, String, Uuid)>>, } #[async_trait] impl FediverseEventObserver for CountingObserver { async fn on_inbox_abuse(&self, _domain: &str, _actor: Option<&str>, reason: &str) { self.calls.fetch_add(1, Ordering::SeqCst); self.reasons.lock().unwrap().push(reason.to_string()); } async fn on_delete_actor(&self, domain: &str, ap_id: &str, remote_identity_id: Uuid) { self.delete_calls.lock().unwrap().push(( domain.to_string(), ap_id.to_string(), remote_identity_id, )); } } fn make_router_with_observer( verifier: Arc<dyn SignatureVerifier>, db: DatabaseConnection, observer: Arc<dyn FediverseEventObserver>, ) -> Router { Router::new() .route("/inbox", post(handle)) .layer(Extension(InboxState { verifier })) .layer(Extension(db)) .layer(Extension(observer)) } #[tokio::test] async fn inbox_emits_malformed_json_observer_event() { let observer = Arc::new(CountingObserver::default()); let router = make_router_with_observer( Arc::new(AlwaysAccept), MockDatabase::new(DatabaseBackend::Postgres).into_connection(), observer.clone(), ); let resp = router.oneshot(signed_request("not json")).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let reasons = observer.reasons.lock().unwrap().clone(); assert_eq!(reasons, vec!["malformed_json".to_string()]); } fn db_for_delete_hit(id: Uuid) -> DatabaseConnection { let now = chrono::Utc::now(); let row = crate::models::remote_identity::Model { id, ap_id: "https://a.tld/users/alice".into(), username: "alice".into(), domain: "a.tld".into(), display_name: None, summary_html: None, inbox_url: "https://a.tld/users/alice/inbox".into(), shared_inbox_url: None, public_key_pem: String::new(), icon_url: None, actor_type: "Person".into(), raw_json: None, last_fetched_at: now, disabled_at: None, created_at: now, updated_at: now, }; let mut after = row.clone(); after.disabled_at = Some(now); permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) .append_query_results([vec![row]]) .append_query_results([vec![after]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection() } #[tokio::test] async fn inbox_emits_delete_actor_observer_event_when_row_disabled() { let id = Uuid::new_v4(); let body = serde_json::json!({ "type": "Delete", "actor": "https://a.tld/users/alice", "object": "https://a.tld/users/alice" }) .to_string(); let observer = Arc::new(CountingObserver::default()); let router = make_router_with_observer( Arc::new(AlwaysAccept), db_for_delete_hit(id), observer.clone(), ); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::ACCEPTED); let calls = observer.delete_calls.lock().unwrap().clone(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "a.tld"); assert_eq!(calls[0].1, "https://a.tld/users/alice"); assert_eq!(calls[0].2, id); } #[tokio::test] async fn inbox_does_not_emit_delete_actor_observer_event_when_row_missing() { let body = serde_json::json!({ "type": "Delete", "actor": "https://a.tld/users/ghost", "object": "https://a.tld/users/ghost" }) .to_string(); let observer = Arc::new(CountingObserver::default()); let router = make_router_with_observer( Arc::new(AlwaysAccept), db_for_delete_noop(), observer.clone(), ); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::ACCEPTED); assert!(observer.delete_calls.lock().unwrap().is_empty()); } #[tokio::test] async fn inbox_emits_unknown_activity_type_observer_event() { let body = serde_json::json!({ "type": "Like", "actor": "https://a.tld/users/alice" }) .to_string(); let observer = Arc::new(CountingObserver::default()); let router = make_router_with_observer( Arc::new(AlwaysAccept), permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), observer.clone(), ); let resp = router.oneshot(signed_request(&body)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); let reasons = observer.reasons.lock().unwrap().clone(); assert_eq!(reasons, vec!["unknown_activity_type".to_string()]); } }
GitRust