observer.rs
171 lignes · 7523 octets
//! Pluggable observer for federation runtime events (RFC 0028, RFC 0032). //! //! The federation layer emits domain- and actor-scoped events at the //! well-defined chokepoints listed in [`FediverseEventObserver`] //! (signature failure, replay, SSRF guard hit, inbox abuse, auth //! challenge, invite action, delete-actor cascade). An external //! observer — most typically the `rustwarden-fediverse-guard` crate — //! receives those events and reacts (logs, counters, auto-ban, //! cascade cleanup, etc.). //! //! The trait is defined here, not in `rustwarden-fediverse-guard`, to //! keep the dependency direction unidirectional: `core ← fediverse ← //! fediverse-guard`. Anyone can implement [`FediverseEventObserver`] //! without depending on the guard crate. //! //! All hook methods carry **no return value**. Implementations must //! treat the federation hot path as untouchable: failures inside the //! observer (DB hiccup, network blip) must never propagate as request //! errors. Use `tracing::warn!` or internal counters instead. //! //! # Default no-op //! //! Every method has a default empty body so a partial implementation //! is fine — a consumer that only cares about signature failures //! overrides `on_signature_failure` and leaves the rest as is. use async_trait::async_trait; use serde_json::Value; use uuid::Uuid; /// Pluggable observer contract. Implementations must be cheap to /// share (`Arc`) and must never block the request path. #[async_trait] pub trait FediverseEventObserver: Send + Sync { /// A signed inbound request failed cryptographic verification. /// /// `domain` is the verified-but-rejected sender (e.g. extracted /// from `keyId`). `actor_uri` is the full AP id when available. /// `reason` is a short machine-stable string ("digest mismatch", /// "bad signature", "key not in cache", …). async fn on_signature_failure(&self, _domain: &str, _actor_uri: Option<&str>, _reason: &str) {} /// A replay was detected (stale `Date` header, duplicate nonce, /// or signature re-use). `details` is a free-form JSON object /// the detector can use to enrich logs. async fn on_replay_attempt(&self, _domain: &str, _actor_uri: Option<&str>, _details: Value) {} /// Local outbound fetcher refused to talk to a private / loopback /// / link-local IP (SSRF guard fired). `target_host` is the host /// part of the rejected URL. async fn on_fetch_ssrf(&self, _target_host: &str, _attempted_ip: Option<&str>) {} /// An inbox payload failed structural validation (body too large, /// schema rejected, unknown activity type, …). async fn on_inbox_abuse(&self, _domain: &str, _actor_uri: Option<&str>, _reason: &str) {} /// An AP-native auth challenge was initiated by a remote IdP. /// `handle` is the requested `@user@domain`. async fn on_auth_challenge(&self, _domain: &str, _handle: &str) {} /// An invite was accepted or rejected (`decision`: `"accept"` | /// `"reject"` | `"revoke"`). async fn on_invite_action(&self, _domain: &str, _decision: &str) {} /// A `Delete{Actor}` activity has been processed by /// [`crate::services::fediverse_signal_service::FediverseSignalService::apply_delete_actor_observed`]: /// the matching `remote_identities` row has been soft-deleted /// (its `disabled_at` is now `NOW()`). Consumers receive this /// hook so they can cascade the deletion onto locally-managed /// artifacts attached to the remote identity (SSH keys, remote /// PATs, team memberships, …). /// /// Invoked **after** the framework's own state mutation is /// committed, so consumer-side lookups by `remote_identity_id` /// observe the soft-deleted row. Idempotent semantics: the /// framework guarantees this hook is fired **at most once per /// actor** per inbound `Delete{Actor}` — if the row was already /// disabled before this delivery, the hook is **not** fired (the /// `rows_disabled` counter on the /// [`crate::services::fediverse_signal_service::DeleteOutcome`] /// returned by the service was 0). /// /// Errors must be swallowed by the implementation — the /// observer is best-effort by contract (RFC 0028). /// /// Arguments: /// - `domain` : lower-cased domain of the deleted actor (always /// populated). /// - `ap_id` : full ActivityPub `id` of the deleted actor (e.g. /// `https://a.tld/users/alice`). /// - `remote_identity_id` : surrogate UUID of the /// `remote_identities` row that was just disabled. Consumers /// key their cleanup on this id (it is the FK pointed at by /// `fediverse_resource_shares.remote_identity_id`, /// `team_members.remote_identity_id`, and any consumer-side /// tables such as `ssh_keys.remote_identity_id`). async fn on_delete_actor(&self, _domain: &str, _ap_id: &str, _remote_identity_id: Uuid) {} } /// No-op observer. Wired as the default extension when no consumer /// registers a real implementation. Permits the federation layer to /// always emit unconditionally without an `Option`. #[derive(Debug, Default, Clone, Copy)] pub struct NoopFediverseObserver; #[async_trait] impl FediverseEventObserver for NoopFediverseObserver {} #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; struct Counting { sig_calls: AtomicUsize, replay_calls: AtomicUsize, } #[async_trait] impl FediverseEventObserver for Counting { async fn on_signature_failure( &self, _domain: &str, _actor_uri: Option<&str>, _reason: &str, ) { self.sig_calls.fetch_add(1, Ordering::SeqCst); } async fn on_replay_attempt( &self, _domain: &str, _actor_uri: Option<&str>, _details: Value, ) { self.replay_calls.fetch_add(1, Ordering::SeqCst); } // other methods inherit the no-op default } #[tokio::test] async fn noop_observer_swallows_every_call() { let obs: Arc<dyn FediverseEventObserver> = Arc::new(NoopFediverseObserver); obs.on_signature_failure("x.tld", None, "bad sig").await; obs.on_replay_attempt("x.tld", None, serde_json::json!({})) .await; obs.on_fetch_ssrf("evil.local", Some("127.0.0.1")).await; obs.on_inbox_abuse("x.tld", None, "oversize").await; obs.on_auth_challenge("x.tld", "@alice@a.tld").await; obs.on_invite_action("x.tld", "accept").await; obs.on_delete_actor("x.tld", "https://x.tld/users/a", Uuid::new_v4()) .await; // Nothing to assert; we only check that defaults compile and // do not panic when called through a trait object. } #[tokio::test] async fn partial_impl_uses_default_for_unspecified_hooks() { let counter = Arc::new(Counting { sig_calls: AtomicUsize::new(0), replay_calls: AtomicUsize::new(0), }); let obs: Arc<dyn FediverseEventObserver> = counter.clone(); obs.on_signature_failure("x.tld", None, "sig").await; obs.on_signature_failure("x.tld", None, "sig").await; obs.on_inbox_abuse("x.tld", None, "huge").await; // default no-op assert_eq!(counter.sig_calls.load(Ordering::SeqCst), 2); assert_eq!(counter.replay_calls.load(Ordering::SeqCst), 0); } }
GitRust