REFACTORE 0033
EBO <eric.bouhana@softalys.com> committé le 2026-05-16 23:42
790c0dd724c951c489cbcdbacb68b98aad5e7524
1 parent(s)
6 fichiers modifiés
+1046
-56
M
crates/rustwarden-fediverse/src/inbox.rs
+100
-3
@@ -18,7 +18,7 @@ use uuid::Uuid;
| 18 | 18 | use super::error::FediverseError; |
| 19 | 19 | use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; |
| 20 | 20 | use crate::services::fediverse_signal_service::{ |
| 21 | - FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, | |
| 21 | + DeleteActorHooks, FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, | |
| 22 | 22 | }; |
| 23 | 23 | |
| 24 | 24 | /// Per-router state injected into the inbox handler. |
@@ -116,7 +116,7 @@ pub async fn handle(
| 116 | 116 | // 2. Dispatch by activity kind. |
| 117 | 117 | match activity.kind.as_str() { |
| 118 | 118 | "Update" => dispatch_update(&db, &activity).await?, |
| 119 | - "Delete" => dispatch_delete(&db, &activity).await?, | |
| 119 | + "Delete" => dispatch_delete(&db, &activity, observer.as_ref()).await?, | |
| 120 | 120 | "Revoke" => dispatch_revoke(&db, &activity).await?, |
| 121 | 121 | other => { |
| 122 | 122 | observer |
@@ -185,6 +185,7 @@ async fn dispatch_update(
| 185 | 185 | async fn dispatch_delete( |
| 186 | 186 | db: &DatabaseConnection, |
| 187 | 187 | activity: &InboxActivity, |
| 188 | + observer: &dyn FediverseEventObserver, | |
| 188 | 189 | ) -> Result<(), FediverseError> { |
| 189 | 190 | // AP `Delete{Actor}` carries the actor URL either as a string in |
| 190 | 191 | // `object` or as `{ "id": "...", "type": "Person" }`. |
@@ -198,7 +199,15 @@ async fn dispatch_delete(
| 198 | 199 | }) |
| 199 | 200 | .ok_or_else(|| FediverseError::BadRequest("Delete missing actor reference".into()))?; |
| 200 | 201 | |
| 201 | - FediverseSignalService::apply_delete_actor(db, &ap_id).await?; | |
| 202 | + FediverseSignalService::apply_delete_actor_with_hooks( | |
| 203 | + db, | |
| 204 | + &ap_id, | |
| 205 | + DeleteActorHooks { | |
| 206 | + observer: Some(observer), | |
| 207 | + ..Default::default() | |
| 208 | + }, | |
| 209 | + ) | |
| 210 | + .await?; | |
| 202 | 211 | Ok(()) |
| 203 | 212 | } |
| 204 | 213 |
@@ -472,6 +481,7 @@ mod tests {
| 472 | 481 | struct CountingObserver { |
| 473 | 482 | reasons: std::sync::Mutex<Vec<String>>, |
| 474 | 483 | calls: AtomicUsize, |
| 484 | + delete_calls: std::sync::Mutex<Vec<(String, String, Uuid)>>, | |
| 475 | 485 | } |
| 476 | 486 | |
| 477 | 487 | #[async_trait] |
@@ -485,6 +495,18 @@ mod tests {
| 485 | 495 | self.calls.fetch_add(1, Ordering::SeqCst); |
| 486 | 496 | self.reasons.lock().unwrap().push(reason.to_string()); |
| 487 | 497 | } |
| 498 | + | |
| 499 | + async fn on_delete_actor( | |
| 500 | + &self, | |
| 501 | + domain: &str, | |
| 502 | + ap_id: &str, | |
| 503 | + remote_identity_id: Uuid, | |
| 504 | + ) { | |
| 505 | + self.delete_calls | |
| 506 | + .lock() | |
| 507 | + .unwrap() | |
| 508 | + .push((domain.to_string(), ap_id.to_string(), remote_identity_id)); | |
| 509 | + } | |
| 488 | 510 | } |
| 489 | 511 | |
| 490 | 512 | fn make_router_with_observer( |
@@ -513,6 +535,81 @@ mod tests {
| 513 | 535 | assert_eq!(reasons, vec!["malformed_json".to_string()]); |
| 514 | 536 | } |
| 515 | 537 | |
| 538 | + fn db_for_delete_hit(id: Uuid) -> DatabaseConnection { | |
| 539 | + let now = chrono::Utc::now(); | |
| 540 | + let row = crate::models::remote_identity::Model { | |
| 541 | + id, | |
| 542 | + ap_id: "https://a.tld/users/alice".into(), | |
| 543 | + username: "alice".into(), | |
| 544 | + domain: "a.tld".into(), | |
| 545 | + display_name: None, | |
| 546 | + summary_html: None, | |
| 547 | + inbox_url: "https://a.tld/users/alice/inbox".into(), | |
| 548 | + shared_inbox_url: None, | |
| 549 | + public_key_pem: String::new(), | |
| 550 | + icon_url: None, | |
| 551 | + actor_type: "Person".into(), | |
| 552 | + raw_json: None, | |
| 553 | + last_fetched_at: now, | |
| 554 | + disabled_at: None, | |
| 555 | + created_at: now, | |
| 556 | + updated_at: now, | |
| 557 | + }; | |
| 558 | + let mut after = row.clone(); | |
| 559 | + after.disabled_at = Some(now); | |
| 560 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 561 | + .append_query_results([vec![row]]) | |
| 562 | + .append_query_results([vec![after]]) | |
| 563 | + .append_exec_results([MockExecResult { | |
| 564 | + last_insert_id: 0, | |
| 565 | + rows_affected: 1, | |
| 566 | + }]) | |
| 567 | + .into_connection() | |
| 568 | + } | |
| 569 | + | |
| 570 | + #[tokio::test] | |
| 571 | + async fn inbox_emits_delete_actor_observer_event_when_row_disabled() { | |
| 572 | + let id = Uuid::new_v4(); | |
| 573 | + let body = serde_json::json!({ | |
| 574 | + "type": "Delete", | |
| 575 | + "actor": "https://a.tld/users/alice", | |
| 576 | + "object": "https://a.tld/users/alice" | |
| 577 | + }) | |
| 578 | + .to_string(); | |
| 579 | + let observer = Arc::new(CountingObserver::default()); | |
| 580 | + let router = make_router_with_observer( | |
| 581 | + Arc::new(AlwaysAccept), | |
| 582 | + db_for_delete_hit(id), | |
| 583 | + observer.clone(), | |
| 584 | + ); | |
| 585 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 586 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 587 | + let calls = observer.delete_calls.lock().unwrap().clone(); | |
| 588 | + assert_eq!(calls.len(), 1); | |
| 589 | + assert_eq!(calls[0].0, "a.tld"); | |
| 590 | + assert_eq!(calls[0].1, "https://a.tld/users/alice"); | |
| 591 | + assert_eq!(calls[0].2, id); | |
| 592 | + } | |
| 593 | + | |
| 594 | + #[tokio::test] | |
| 595 | + async fn inbox_does_not_emit_delete_actor_observer_event_when_row_missing() { | |
| 596 | + let body = serde_json::json!({ | |
| 597 | + "type": "Delete", | |
| 598 | + "actor": "https://a.tld/users/ghost", | |
| 599 | + "object": "https://a.tld/users/ghost" | |
| 600 | + }) | |
| 601 | + .to_string(); | |
| 602 | + let observer = Arc::new(CountingObserver::default()); | |
| 603 | + let router = make_router_with_observer( | |
| 604 | + Arc::new(AlwaysAccept), | |
| 605 | + db_for_delete_noop(), | |
| 606 | + observer.clone(), | |
| 607 | + ); | |
| 608 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 609 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 610 | + assert!(observer.delete_calls.lock().unwrap().is_empty()); | |
| 611 | + } | |
| 612 | + | |
| 516 | 613 | #[tokio::test] |
| 517 | 614 | async fn inbox_emits_unknown_activity_type_observer_event() { |
| 518 | 615 | let body = serde_json::json!({ |
M
crates/rustwarden-fediverse/src/observer.rs
+52
-7
@@ -1,10 +1,12 @@
| 1 | -//! Pluggable observer for federation runtime events (RFC 0028). | |
| 1 | +//! Pluggable observer for federation runtime events (RFC 0028, RFC 0032). | |
| 2 | 2 | //! |
| 3 | -//! The federation layer emits domain- and actor-scoped events at five | |
| 4 | -//! well-defined chokepoints (signature failure, replay, SSRF guard | |
| 5 | -//! hit, auth challenge, invite action). An external observer — most | |
| 6 | -//! typically the `rustwarden-fediverse-guard` crate — receives those | |
| 7 | -//! events and reacts (logs, counters, auto-ban, etc.). | |
| 3 | +//! The federation layer emits domain- and actor-scoped events at the | |
| 4 | +//! well-defined chokepoints listed in [`FediverseEventObserver`] | |
| 5 | +//! (signature failure, replay, SSRF guard hit, inbox abuse, auth | |
| 6 | +//! challenge, invite action, delete-actor cascade). An external | |
| 7 | +//! observer — most typically the `rustwarden-fediverse-guard` crate — | |
| 8 | +//! receives those events and reacts (logs, counters, auto-ban, | |
| 9 | +//! cascade cleanup, etc.). | |
| 8 | 10 | //! |
| 9 | 11 | //! The trait is defined here, not in `rustwarden-fediverse-guard`, to |
| 10 | 12 | //! keep the dependency direction unidirectional: `core ← fediverse ← |
@@ -24,8 +26,9 @@
| 24 | 26 | |
| 25 | 27 | use async_trait::async_trait; |
| 26 | 28 | use serde_json::Value; |
| 29 | +use uuid::Uuid; | |
| 27 | 30 | |
| 28 | -/// Five-method observer contract. Implementations must be cheap to | |
| 31 | +/// Pluggable observer contract. Implementations must be cheap to | |
| 29 | 32 | /// share (`Arc`) and must never block the request path. |
| 30 | 33 | #[async_trait] |
| 31 | 34 | pub trait FediverseEventObserver: Send + Sync { |
@@ -76,6 +79,46 @@ pub trait FediverseEventObserver: Send + Sync {
| 76 | 79 | /// An invite was accepted or rejected (`decision`: `"accept"` | |
| 77 | 80 | /// `"reject"` | `"revoke"`). |
| 78 | 81 | async fn on_invite_action(&self, _domain: &str, _decision: &str) {} |
| 82 | + | |
| 83 | + /// A `Delete{Actor}` activity has been processed by | |
| 84 | + /// [`crate::services::fediverse_signal_service::FediverseSignalService::apply_delete_actor_observed`]: | |
| 85 | + /// the matching `remote_identities` row has been soft-deleted | |
| 86 | + /// (its `disabled_at` is now `NOW()`). Consumers receive this | |
| 87 | + /// hook so they can cascade the deletion onto locally-managed | |
| 88 | + /// artifacts attached to the remote identity (SSH keys, remote | |
| 89 | + /// PATs, team memberships, …). | |
| 90 | + /// | |
| 91 | + /// Invoked **after** the framework's own state mutation is | |
| 92 | + /// committed, so consumer-side lookups by `remote_identity_id` | |
| 93 | + /// observe the soft-deleted row. Idempotent semantics: the | |
| 94 | + /// framework guarantees this hook is fired **at most once per | |
| 95 | + /// actor** per inbound `Delete{Actor}` — if the row was already | |
| 96 | + /// disabled before this delivery, the hook is **not** fired (the | |
| 97 | + /// `rows_disabled` counter on the | |
| 98 | + /// [`crate::services::fediverse_signal_service::DeleteOutcome`] | |
| 99 | + /// returned by the service was 0). | |
| 100 | + /// | |
| 101 | + /// Errors must be swallowed by the implementation — the | |
| 102 | + /// observer is best-effort by contract (RFC 0028). | |
| 103 | + /// | |
| 104 | + /// Arguments: | |
| 105 | + /// - `domain` : lower-cased domain of the deleted actor (always | |
| 106 | + /// populated). | |
| 107 | + /// - `ap_id` : full ActivityPub `id` of the deleted actor (e.g. | |
| 108 | + /// `https://a.tld/users/alice`). | |
| 109 | + /// - `remote_identity_id` : surrogate UUID of the | |
| 110 | + /// `remote_identities` row that was just disabled. Consumers | |
| 111 | + /// key their cleanup on this id (it is the FK pointed at by | |
| 112 | + /// `fediverse_resource_shares.remote_identity_id`, | |
| 113 | + /// `team_members.remote_identity_id`, and any consumer-side | |
| 114 | + /// tables such as `ssh_keys.remote_identity_id`). | |
| 115 | + async fn on_delete_actor( | |
| 116 | + &self, | |
| 117 | + _domain: &str, | |
| 118 | + _ap_id: &str, | |
| 119 | + _remote_identity_id: Uuid, | |
| 120 | + ) { | |
| 121 | + } | |
| 79 | 122 | } |
| 80 | 123 | |
| 81 | 124 | /// No-op observer. Wired as the default extension when no consumer |
@@ -129,6 +172,8 @@ mod tests {
| 129 | 172 | obs.on_inbox_abuse("x.tld", None, "oversize").await; |
| 130 | 173 | obs.on_auth_challenge("x.tld", "@alice@a.tld").await; |
| 131 | 174 | obs.on_invite_action("x.tld", "accept").await; |
| 175 | + obs.on_delete_actor("x.tld", "https://x.tld/users/a", Uuid::new_v4()) | |
| 176 | + .await; | |
| 132 | 177 | // Nothing to assert; we only check that defaults compile and |
| 133 | 178 | // do not panic when called through a trait object. |
| 134 | 179 | } |
M
crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs
+279
-42
@@ -40,17 +40,45 @@ pub struct UpdatedActor {
| 40 | 40 | pub raw_json: Option<serde_json::Value>, |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | -/// Counters returned by [`FediverseSignalService::apply_delete_actor`]. | |
| 43 | +/// Counters returned by [`FediverseSignalService::apply_delete_actor_with_hooks`]. | |
| 44 | 44 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 45 | 45 | pub struct DeleteOutcome { |
| 46 | 46 | /// Number of `remote_identities` rows soft-deleted. |
| 47 | 47 | pub rows_disabled: u64, |
| 48 | - /// Number of JWTs blacklisted (always `0` for the RFC 0010 slice; | |
| 49 | - /// a complementary `JwtBlacklistService::blacklist_by_remote_identity_id` | |
| 50 | - /// API will close that gap in a follow-up). | |
| 48 | + /// Number of JWTs blacklisted (always `0` when no | |
| 49 | + /// [`DeleteActorHooks::iterator`] is supplied). | |
| 51 | 50 | pub jwts_blacklisted: u64, |
| 52 | 51 | } |
| 53 | 52 | |
| 53 | +/// Pluggable side-effects for a remote-actor deletion (RFC 0033). | |
| 54 | +/// | |
| 55 | +/// All fields are `Option` with `None` as default (no-op). The | |
| 56 | +/// recommended construction idiom is the struct-literal pattern with | |
| 57 | +/// `..Default::default()`, which keeps existing call-sites compiling | |
| 58 | +/// when new hooks get added in future RFCs: | |
| 59 | +/// | |
| 60 | +/// ```rust,ignore | |
| 61 | +/// use rustwarden_fediverse::services::fediverse_signal_service::DeleteActorHooks; | |
| 62 | +/// let hooks = DeleteActorHooks { | |
| 63 | +/// observer: Some(&my_observer), | |
| 64 | +/// ..Default::default() | |
| 65 | +/// }; | |
| 66 | +/// ``` | |
| 67 | +/// | |
| 68 | +/// The struct is **not** `#[non_exhaustive]` so consumers can keep | |
| 69 | +/// instantiating it with struct literals; rely on | |
| 70 | +/// `..Default::default()` for forward-compatibility instead. | |
| 71 | +#[derive(Default)] | |
| 72 | +pub struct DeleteActorHooks<'a> { | |
| 73 | + /// Best-effort observer notified **after** the `disabled_at` | |
| 74 | + /// flip has been committed (RFC 0032). `None` ⇒ no hook fires. | |
| 75 | + pub observer: Option<&'a dyn FediverseEventObserver>, | |
| 76 | + /// Enumerator of the active JWTs to blacklist for the deleted | |
| 77 | + /// remote identity (RFC 0018). `None` ⇒ `jwts_blacklisted = 0`. | |
| 78 | + pub iterator: | |
| 79 | + Option<&'a dyn rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator>, | |
| 80 | +} | |
| 81 | + | |
| 54 | 82 | /// Stateless service that applies AP lifecycle signals. |
| 55 | 83 | pub struct FediverseSignalService; |
| 56 | 84 |
@@ -117,19 +145,31 @@ impl FediverseSignalService {
| 117 | 145 | } |
| 118 | 146 | } |
| 119 | 147 | |
| 120 | - /// Apply an inbound `Delete{Actor}`: flip `disabled_at` to NOW for | |
| 121 | - /// the matching `remote_identities` row. JWT blacklisting is | |
| 122 | - /// returned as `0` — see [`DeleteOutcome::jwts_blacklisted`]. | |
| 123 | - pub async fn apply_delete_actor( | |
| 148 | + /// Canonical entrypoint for an inbound `Delete{Actor}` (RFC 0033). | |
| 149 | + /// | |
| 150 | + /// Combines the semantics of every historical variant: | |
| 151 | + /// | |
| 152 | + /// 1. Flip `remote_identities.disabled_at` to `NOW()` | |
| 153 | + /// (idempotent: a re-delivery does nothing). | |
| 154 | + /// 2. If [`DeleteActorHooks::iterator`] is `Some`, blacklist every | |
| 155 | + /// active JWT enumerated for the deleted `remote_identity_id` | |
| 156 | + /// (RFC 0018). | |
| 157 | + /// 3. If [`DeleteActorHooks::observer`] is `Some`, fire | |
| 158 | + /// [`FediverseEventObserver::on_delete_actor`] **after** the | |
| 159 | + /// state mutation is committed (RFC 0032). | |
| 160 | + /// | |
| 161 | + /// Steps 2 and 3 only run on a real transition (active → | |
| 162 | + /// disabled). Re-deliveries and unknown actors are silent. | |
| 163 | + pub async fn apply_delete_actor_with_hooks( | |
| 124 | 164 | db: &DatabaseConnection, |
| 125 | 165 | ap_id: &str, |
| 166 | + hooks: DeleteActorHooks<'_>, | |
| 126 | 167 | ) -> Result<DeleteOutcome, AppError> { |
| 127 | - let model = remote_identity::Entity::find() | |
| 168 | + let Some(row) = remote_identity::Entity::find() | |
| 128 | 169 | .filter(remote_identity::Column::ApId.eq(ap_id)) |
| 129 | 170 | .one(db) |
| 130 | - .await?; | |
| 131 | - | |
| 132 | - let Some(row) = model else { | |
| 171 | + .await? | |
| 172 | + else { | |
| 133 | 173 | return Ok(DeleteOutcome::default()); |
| 134 | 174 | }; |
| 135 | 175 |
@@ -140,45 +180,79 @@ impl FediverseSignalService {
| 140 | 180 | }); |
| 141 | 181 | } |
| 142 | 182 | |
| 183 | + let domain = row.domain.clone(); | |
| 184 | + let remote_identity_id = row.id; | |
| 185 | + | |
| 143 | 186 | let mut active: remote_identity::ActiveModel = row.into(); |
| 144 | 187 | active.disabled_at = Set(Some(Utc::now())); |
| 145 | 188 | active.update(db).await?; |
| 146 | 189 | |
| 190 | + let jwts_blacklisted = if let Some(it) = hooks.iterator { | |
| 191 | + rustwarden_core::services::jwt_blacklist_service::JwtBlacklistService::blacklist_by_remote_identity_id( | |
| 192 | + db, | |
| 193 | + remote_identity_id, | |
| 194 | + it, | |
| 195 | + ) | |
| 196 | + .await? | |
| 197 | + } else { | |
| 198 | + 0 | |
| 199 | + }; | |
| 200 | + | |
| 201 | + if let Some(observer) = hooks.observer { | |
| 202 | + observer | |
| 203 | + .on_delete_actor(&domain, ap_id, remote_identity_id) | |
| 204 | + .await; | |
| 205 | + } | |
| 206 | + | |
| 147 | 207 | Ok(DeleteOutcome { |
| 148 | 208 | rows_disabled: 1, |
| 149 | - jwts_blacklisted: 0, | |
| 209 | + jwts_blacklisted, | |
| 150 | 210 | }) |
| 151 | 211 | } |
| 152 | 212 | |
| 153 | - /// RFC 0018 extension: same as [`Self::apply_delete_actor`] but | |
| 154 | - /// also blacklists every active JWT issued for the deleted remote | |
| 155 | - /// identity. The `iterator` argument is pluggable | |
| 156 | - /// ([`rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator`]) — | |
| 157 | - /// pass `&NoopActiveTokenIterator` to keep the legacy behaviour. | |
| 213 | + /// Compatibility wrapper. Prefer | |
| 214 | + /// [`Self::apply_delete_actor_with_hooks`] for new code. | |
| 215 | + pub async fn apply_delete_actor( | |
| 216 | + db: &DatabaseConnection, | |
| 217 | + ap_id: &str, | |
| 218 | + ) -> Result<DeleteOutcome, AppError> { | |
| 219 | + Self::apply_delete_actor_with_hooks(db, ap_id, DeleteActorHooks::default()).await | |
| 220 | + } | |
| 221 | + | |
| 222 | + /// Compatibility wrapper. Prefer | |
| 223 | + /// [`Self::apply_delete_actor_with_hooks`] for new code. | |
| 224 | + pub async fn apply_delete_actor_observed( | |
| 225 | + db: &DatabaseConnection, | |
| 226 | + ap_id: &str, | |
| 227 | + observer: &dyn FediverseEventObserver, | |
| 228 | + ) -> Result<DeleteOutcome, AppError> { | |
| 229 | + Self::apply_delete_actor_with_hooks( | |
| 230 | + db, | |
| 231 | + ap_id, | |
| 232 | + DeleteActorHooks { | |
| 233 | + observer: Some(observer), | |
| 234 | + ..Default::default() | |
| 235 | + }, | |
| 236 | + ) | |
| 237 | + .await | |
| 238 | + } | |
| 239 | + | |
| 240 | + /// Compatibility wrapper. Prefer | |
| 241 | + /// [`Self::apply_delete_actor_with_hooks`] for new code. | |
| 158 | 242 | pub async fn apply_delete_actor_with_iterator( |
| 159 | 243 | db: &DatabaseConnection, |
| 160 | 244 | ap_id: &str, |
| 161 | 245 | iterator: &dyn rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator, |
| 162 | 246 | ) -> Result<DeleteOutcome, AppError> { |
| 163 | - let mut outcome = Self::apply_delete_actor(db, ap_id).await?; | |
| 164 | - if outcome.rows_disabled == 0 { | |
| 165 | - return Ok(outcome); | |
| 166 | - } | |
| 167 | - // Re-fetch the row to learn its `id` (the `apply_delete_actor` | |
| 168 | - // call above only returned counters). | |
| 169 | - let model = remote_identity::Entity::find() | |
| 170 | - .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 171 | - .one(db) | |
| 172 | - .await?; | |
| 173 | - if let Some(row) = model { | |
| 174 | - outcome.jwts_blacklisted = rustwarden_core::services::jwt_blacklist_service::JwtBlacklistService::blacklist_by_remote_identity_id( | |
| 175 | - db, | |
| 176 | - row.id, | |
| 177 | - iterator, | |
| 178 | - ) | |
| 179 | - .await?; | |
| 180 | - } | |
| 181 | - Ok(outcome) | |
| 247 | + Self::apply_delete_actor_with_hooks( | |
| 248 | + db, | |
| 249 | + ap_id, | |
| 250 | + DeleteActorHooks { | |
| 251 | + iterator: Some(iterator), | |
| 252 | + ..Default::default() | |
| 253 | + }, | |
| 254 | + ) | |
| 255 | + .await | |
| 182 | 256 | } |
| 183 | 257 | |
| 184 | 258 | /// Apply an inbound `Revoke{Membership}`: drop the matching |
@@ -777,6 +851,97 @@ mod tests {
| 777 | 851 | assert_eq!(outcome.rows_disabled, 0); |
| 778 | 852 | } |
| 779 | 853 | |
| 854 | + // ─── apply_delete_actor_observed (RFC 0032) ───────────────────── | |
| 855 | + | |
| 856 | + #[derive(Default)] | |
| 857 | + struct CapturingDeleteObserver { | |
| 858 | + calls: std::sync::Mutex<Vec<(String, String, Uuid)>>, | |
| 859 | + } | |
| 860 | + | |
| 861 | + #[async_trait] | |
| 862 | + impl FediverseEventObserver for CapturingDeleteObserver { | |
| 863 | + async fn on_delete_actor( | |
| 864 | + &self, | |
| 865 | + domain: &str, | |
| 866 | + ap_id: &str, | |
| 867 | + remote_identity_id: Uuid, | |
| 868 | + ) { | |
| 869 | + self.calls | |
| 870 | + .lock() | |
| 871 | + .unwrap() | |
| 872 | + .push((domain.to_string(), ap_id.to_string(), remote_identity_id)); | |
| 873 | + } | |
| 874 | + } | |
| 875 | + | |
| 876 | + #[tokio::test] | |
| 877 | + async fn apply_delete_actor_observed_fires_hook_on_first_disable() { | |
| 878 | + let id = Uuid::new_v4(); | |
| 879 | + let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 880 | + let mut after = row.clone(); | |
| 881 | + after.disabled_at = Some(Utc::now()); | |
| 882 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 883 | + .append_query_results([vec![row]]) | |
| 884 | + .append_query_results([vec![after]]) | |
| 885 | + .append_exec_results([MockExecResult { | |
| 886 | + last_insert_id: 0, | |
| 887 | + rows_affected: 1, | |
| 888 | + }]) | |
| 889 | + .into_connection(); | |
| 890 | + | |
| 891 | + let observer = CapturingDeleteObserver::default(); | |
| 892 | + let outcome = FediverseSignalService::apply_delete_actor_observed( | |
| 893 | + &db, | |
| 894 | + "https://a.tld/users/alice", | |
| 895 | + &observer, | |
| 896 | + ) | |
| 897 | + .await | |
| 898 | + .unwrap(); | |
| 899 | + | |
| 900 | + assert_eq!(outcome.rows_disabled, 1); | |
| 901 | + let calls = observer.calls.lock().unwrap().clone(); | |
| 902 | + assert_eq!(calls.len(), 1); | |
| 903 | + assert_eq!(calls[0].0, "a.tld"); | |
| 904 | + assert_eq!(calls[0].1, "https://a.tld/users/alice"); | |
| 905 | + assert_eq!(calls[0].2, id); | |
| 906 | + } | |
| 907 | + | |
| 908 | + #[tokio::test] | |
| 909 | + async fn apply_delete_actor_observed_does_not_fire_when_no_row() { | |
| 910 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 911 | + .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 912 | + .into_connection(); | |
| 913 | + let observer = CapturingDeleteObserver::default(); | |
| 914 | + let outcome = FediverseSignalService::apply_delete_actor_observed( | |
| 915 | + &db, | |
| 916 | + "https://a.tld/users/ghost", | |
| 917 | + &observer, | |
| 918 | + ) | |
| 919 | + .await | |
| 920 | + .unwrap(); | |
| 921 | + assert_eq!(outcome, DeleteOutcome::default()); | |
| 922 | + assert!(observer.calls.lock().unwrap().is_empty()); | |
| 923 | + } | |
| 924 | + | |
| 925 | + #[tokio::test] | |
| 926 | + async fn apply_delete_actor_observed_does_not_fire_when_already_disabled() { | |
| 927 | + let id = Uuid::new_v4(); | |
| 928 | + let mut row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 929 | + row.disabled_at = Some(Utc::now()); | |
| 930 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 931 | + .append_query_results([vec![row]]) | |
| 932 | + .into_connection(); | |
| 933 | + let observer = CapturingDeleteObserver::default(); | |
| 934 | + let outcome = FediverseSignalService::apply_delete_actor_observed( | |
| 935 | + &db, | |
| 936 | + "https://a.tld/users/alice", | |
| 937 | + &observer, | |
| 938 | + ) | |
| 939 | + .await | |
| 940 | + .unwrap(); | |
| 941 | + assert_eq!(outcome.rows_disabled, 0); | |
| 942 | + assert!(observer.calls.lock().unwrap().is_empty()); | |
| 943 | + } | |
| 944 | + | |
| 780 | 945 | // ─── apply_revoke_membership ──────────────────────────────────── |
| 781 | 946 | |
| 782 | 947 | #[tokio::test] |
@@ -856,12 +1021,10 @@ mod tests {
| 856 | 1021 | let row = fresh_remote(id, "https://a.tld/users/alice"); |
| 857 | 1022 | let mut after = row.clone(); |
| 858 | 1023 | after.disabled_at = Some(Utc::now()); |
| 1024 | + // RFC 0033: the unified path needs only SELECT + UPDATE (no | |
| 1025 | + // re-fetch); the iterator is a Noop so no blacklist write. | |
| 859 | 1026 | let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) |
| 860 | - // SELECT for apply_delete_actor | |
| 861 | - .append_query_results([vec![row.clone()]]) | |
| 862 | - // SELECT issued by .update() inside apply_delete_actor | |
| 863 | - .append_query_results([vec![after.clone()]]) | |
| 864 | - // SELECT in apply_delete_actor_with_iterator (re-fetch) | |
| 1027 | + .append_query_results([vec![row]]) | |
| 865 | 1028 | .append_query_results([vec![after]]) |
| 866 | 1029 | .append_exec_results([MockExecResult { |
| 867 | 1030 | last_insert_id: 0, |
@@ -879,6 +1042,80 @@ mod tests {
| 879 | 1042 | assert_eq!(outcome.jwts_blacklisted, 0); |
| 880 | 1043 | } |
| 881 | 1044 | |
| 1045 | + // ─── apply_delete_actor_with_hooks (RFC 0033) ─────────────────── | |
| 1046 | + | |
| 1047 | + #[test] | |
| 1048 | + fn delete_actor_hooks_default_is_all_none() { | |
| 1049 | + let hooks = DeleteActorHooks::default(); | |
| 1050 | + assert!(hooks.observer.is_none()); | |
| 1051 | + assert!(hooks.iterator.is_none()); | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + #[tokio::test] | |
| 1055 | + async fn apply_delete_actor_with_hooks_combines_observer_and_iterator() { | |
| 1056 | + use rustwarden_core::services::jwt_blacklist_service::NoopActiveTokenIterator; | |
| 1057 | + let id = Uuid::new_v4(); | |
| 1058 | + let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 1059 | + let mut after = row.clone(); | |
| 1060 | + after.disabled_at = Some(Utc::now()); | |
| 1061 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 1062 | + .append_query_results([vec![row]]) | |
| 1063 | + .append_query_results([vec![after]]) | |
| 1064 | + .append_exec_results([MockExecResult { | |
| 1065 | + last_insert_id: 0, | |
| 1066 | + rows_affected: 1, | |
| 1067 | + }]) | |
| 1068 | + .into_connection(); | |
| 1069 | + | |
| 1070 | + let observer = CapturingDeleteObserver::default(); | |
| 1071 | + let iterator = NoopActiveTokenIterator; | |
| 1072 | + let hooks = DeleteActorHooks { | |
| 1073 | + observer: Some(&observer), | |
| 1074 | + iterator: Some(&iterator), | |
| 1075 | + }; | |
| 1076 | + | |
| 1077 | + let outcome = FediverseSignalService::apply_delete_actor_with_hooks( | |
| 1078 | + &db, | |
| 1079 | + "https://a.tld/users/alice", | |
| 1080 | + hooks, | |
| 1081 | + ) | |
| 1082 | + .await | |
| 1083 | + .unwrap(); | |
| 1084 | + | |
| 1085 | + assert_eq!(outcome.rows_disabled, 1); | |
| 1086 | + assert_eq!(outcome.jwts_blacklisted, 0); | |
| 1087 | + let calls = observer.calls.lock().unwrap().clone(); | |
| 1088 | + assert_eq!(calls.len(), 1); | |
| 1089 | + assert_eq!(calls[0].2, id); | |
| 1090 | + } | |
| 1091 | + | |
| 1092 | + #[tokio::test] | |
| 1093 | + async fn apply_delete_actor_with_hooks_skips_both_when_already_disabled() { | |
| 1094 | + use rustwarden_core::services::jwt_blacklist_service::NoopActiveTokenIterator; | |
| 1095 | + let id = Uuid::new_v4(); | |
| 1096 | + let mut row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 1097 | + row.disabled_at = Some(Utc::now()); | |
| 1098 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 1099 | + .append_query_results([vec![row]]) | |
| 1100 | + .into_connection(); | |
| 1101 | + | |
| 1102 | + let observer = CapturingDeleteObserver::default(); | |
| 1103 | + let iterator = NoopActiveTokenIterator; | |
| 1104 | + let outcome = FediverseSignalService::apply_delete_actor_with_hooks( | |
| 1105 | + &db, | |
| 1106 | + "https://a.tld/users/alice", | |
| 1107 | + DeleteActorHooks { | |
| 1108 | + observer: Some(&observer), | |
| 1109 | + iterator: Some(&iterator), | |
| 1110 | + }, | |
| 1111 | + ) | |
| 1112 | + .await | |
| 1113 | + .unwrap(); | |
| 1114 | + assert_eq!(outcome.rows_disabled, 0); | |
| 1115 | + assert_eq!(outcome.jwts_blacklisted, 0); | |
| 1116 | + assert!(observer.calls.lock().unwrap().is_empty()); | |
| 1117 | + } | |
| 1118 | + | |
| 882 | 1119 | #[tokio::test] |
| 883 | 1120 | async fn noop_active_token_iterator_returns_empty_list() { |
| 884 | 1121 | use rustwarden_core::services::jwt_blacklist_service::{ |
M
docs/SERVICES_REFERENCE.md
+22
-4
@@ -1236,11 +1236,29 @@ Application des signaux lifecycle entrants sur l'état local (cache `remote_iden
| 1236 | 1236 | | Méthode | Signature | Description | |
| 1237 | 1237 | |---|---|---| |
| 1238 | 1238 | | `apply_update_actor` | `(db, actor: UpdatedActor) → Result<remote_identity::Model>` | Upsert dans `remote_identities` (insert si inconnu, update sinon). Retourne la row finale. | |
| 1239 | -| `apply_delete_actor` | `(db, ap_id: &str) → Result<DeleteOutcome>` | Marque `disabled_at = NOW()`. `DeleteOutcome.rows_affected` indique 0 ou 1. | | |
| 1240 | -| `apply_delete_actor_with_iterator` | `(db, ap_id, iter: &dyn ActiveTokenIterator) → Result<DeleteOutcome>` | Variante qui blackliste **en cascade** tous les JWT remote portant ce `remote_id` (RFC 0018). Le consommateur injecte un `ActiveTokenIterator` (cf. `rustwarden_core::services::jwt_blacklist_service`) pour énumérer les JWT actifs. | | |
| 1239 | +| `apply_delete_actor_with_hooks` | `(db, ap_id: &str, hooks: DeleteActorHooks) → Result<DeleteOutcome>` | **Entrypoint canonique (RFC 0033)**. Flip `disabled_at = NOW()`, puis si `hooks.iterator` est `Some` blackliste les JWT actifs (RFC 0018), puis si `hooks.observer` est `Some` fire `on_delete_actor` post-commit (RFC 0032). Idempotent : seules les transitions actif→désactivé déclenchent les hooks. | | |
| 1240 | +| `apply_delete_actor` | `(db, ap_id) → Result<DeleteOutcome>` | Wrapper de compatibilité — équivalent à `apply_delete_actor_with_hooks(db, ap_id, DeleteActorHooks::default())`. | | |
| 1241 | +| `apply_delete_actor_observed` | `(db, ap_id, observer: &dyn FediverseEventObserver) → Result<DeleteOutcome>` | Wrapper de compatibilité (RFC 0032) — pose `hooks.observer = Some(...)`. | | |
| 1242 | +| `apply_delete_actor_with_iterator` | `(db, ap_id, iter: &dyn ActiveTokenIterator) → Result<DeleteOutcome>` | Wrapper de compatibilité (RFC 0018) — pose `hooks.iterator = Some(...)`. | | |
| 1241 | 1243 | | `apply_revoke_membership` | `(db, team_id, remote_identity_id) → Result<bool>` | Drop la row `team_members` correspondante. Retourne `true` si une row a été supprimée. | |
| 1242 | 1244 | |
| 1243 | -Les trois `apply_*` sont idempotents et sûrs en concurrence : un même signal rejoué ne provoque pas d'effet de bord supplémentaire. | |
| 1245 | +Les `apply_*` sont idempotents et sûrs en concurrence : un même signal rejoué ne provoque pas d'effet de bord supplémentaire. Pour combiner observer + iterator (cleanup local **et** blacklist JWT), utiliser `apply_delete_actor_with_hooks` directement : | |
| 1246 | + | |
| 1247 | +```rust | |
| 1248 | +use rustwarden_fediverse::services::fediverse_signal_service::{ | |
| 1249 | + DeleteActorHooks, FediverseSignalService, | |
| 1250 | +}; | |
| 1251 | +FediverseSignalService::apply_delete_actor_with_hooks( | |
| 1252 | + &db, | |
| 1253 | + ap_id, | |
| 1254 | + DeleteActorHooks { | |
| 1255 | + observer: Some(&my_observer), | |
| 1256 | + iterator: Some(&my_iterator), | |
| 1257 | + }, | |
| 1258 | +).await?; | |
| 1259 | +``` | |
| 1260 | + | |
| 1261 | +Le pattern recommandé pour future-proofer la construction est le struct literal avec `..Default::default()` (la struct n'est pas `#[non_exhaustive]` mais l'ajout futur de champs ne cassera pas les call-sites qui suivent ce pattern). | |
| 1244 | 1262 | |
| 1245 | 1263 | ### 21.6 `FediverseInviteService` |
| 1246 | 1264 |
@@ -1469,7 +1487,7 @@ if plaintext.starts_with(TOKEN_PREFIX) {
| 1469 | 1487 | } |
| 1470 | 1488 | ``` |
| 1471 | 1489 | |
| 1472 | -> **Cascade `Delete{Actor}`** : `revoke_all_for_remote_identity` est l'API que la pipeline lifecycle ([FediverseSignalService::apply_delete_actor_with_iterator](#215-fediversesignalservice)) doit appeler pour neutraliser les PAT d'une identité distante désactivée. Le câblage automatique n'est pas (encore) en place — à empiler. | |
| 1490 | +> **Cascade `Delete{Actor}`** : `revoke_all_for_remote_identity` est l'API qu'un consommateur câble depuis son impl de [`FediverseEventObserver::on_delete_actor`](#215-fediversesignalservice) (RFC 0032/0033) pour neutraliser les PAT d'une identité distante désactivée. La pipeline framework fournit le signal post-commit ; la révocation reste de la responsabilité du consommateur (le framework ne sait pas quels artefacts locaux dépendent de la remote identity). | |
| 1473 | 1491 | |
| 1474 | 1492 | ### 21.14 Patterns d'intégration |
| 1475 | 1493 |
A
docs/rfcs/0032-fediverse-event-observer-delete-actor.md
+250
-0
@@ -0,0 +1,250 @@
| 1 | +# RFC 0004 — Ajouter `on_delete_actor` à `FediverseEventObserver` | |
| 2 | + | |
| 3 | +> Numéro local gitrust. Upstream assignera vraisemblablement 0032+ à la livraison. | |
| 4 | + | |
| 5 | +- **Auteur** : EBO (gitrust) | |
| 6 | +- **Date** : 2026-05-17 | |
| 7 | +- **Statut** : Draft (à pousser upstream) | |
| 8 | +- **Crate cible** : `rustwarden-fediverse` | |
| 9 | +- **Type** : extension trait additive (pas de breaking change pour les consommateurs existants — ajout d'une méthode `default` au trait `FediverseEventObserver`) | |
| 10 | + | |
| 11 | +## Summary | |
| 12 | + | |
| 13 | +Ajouter une 6ᵉ méthode `on_delete_actor(domain, ap_id, rows_disabled)` au trait [`FediverseEventObserver`](../../crates/rustwarden-fediverse/src/observer.rs#L31) et l'invoquer depuis [`FediverseSignalService::apply_delete_actor`](../../crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs#L123) après que `disabled_at` ait été flippé. | |
| 14 | + | |
| 15 | +Cela permet aux consommateurs (gitrust en premier lieu) de réagir **synchronement** à une désactivation de remote identity sans avoir à dupliquer la logique d'inbox ou à polluer leur DB via un sweep périodique. | |
| 16 | + | |
| 17 | +## Motivation | |
| 18 | + | |
| 19 | +### Contexte | |
| 20 | + | |
| 21 | +Le framework livre la fédération depuis les RFCs 0001-0028. Quand un peer envoie un `Delete{Actor}`, le handler `/inbox` du framework appelle `FediverseSignalService::apply_delete_actor` qui flip `remote_identities.disabled_at` à `NOW()`. La row reste en DB (soft-delete intentionnel pour audit + future re-enable). | |
| 22 | + | |
| 23 | +Côté **consommateur**, cette désactivation doit cascader sur les artefacts locaux attachés à la remote identity : credentials (SSH keys, PATs locaux pour l'utilisateur remote), memberships, partages de ressources. Sinon un attaquant qui a obtenu un Delete{Actor} pour un peer reste actif sur l'instance locale. | |
| 24 | + | |
| 25 | +### Problème pour gitrust (~Lot 6 du plan fédération) | |
| 26 | + | |
| 27 | +[Plan d'intégration fediverse §555-562](../plans/2026-05-15-fediverse-integration-gitrust.md) : | |
| 28 | + | |
| 29 | +> Une remote_identity passe en `disabled_at IS NOT NULL` quand son instance source envoie un `Delete{Actor}`. Cette désactivation doit cascader sur les artefacts locaux gitrust pour qu'alice ne puisse plus opérer. | |
| 30 | + | |
| 31 | +Gitrust a 3 axes de cleanup : | |
| 32 | +- **SSH keys** : table gitrust `ssh_keys.remote_identity_id` — pas de FK CASCADE possible (cross-tracker ordering interdit, cf. migration `m20260520_000025`) | |
| 33 | +- **Remote PATs** : table framework `fediverse_personal_access_tokens` — `RemotePersonalAccessTokenService::revoke_all_for_remote_identity` existe déjà mais n'est pas appelé automatiquement | |
| 34 | +- **Memberships / shares** : conservés volontairement pour audit, mais l'admin peut vouloir scripter leur révocation | |
| 35 | + | |
| 36 | +### État du framework actuel | |
| 37 | + | |
| 38 | +[`RustwardenHooks`](../../crates/rustwarden-core/src/hooks.rs) : 8 méthodes lifecycle (user/resource/team), **aucune** sur les remote identities. | |
| 39 | + | |
| 40 | +[`FediverseEventObserver`](../../crates/rustwarden-fediverse/src/observer.rs) : 6 méthodes (signature_failure, replay_attempt, fetch_ssrf, inbox_abuse, auth_challenge, invite_action). **Aucune** sur delete_actor. | |
| 41 | + | |
| 42 | +→ Le consommateur n'a aucun hook propre pour réagir au `Delete{Actor}`. Les options actuelles sont toutes contournées : | |
| 43 | + | |
| 44 | +| Approche | Coût | Pourquoi insatisfaisante | | |
| 45 | +|---|---|---| | |
| 46 | +| Sweep périodique côté consommateur | ~5 min de latence cleanup | Une fenêtre `Delete{Actor}` reçu → SSH key révoquée de plusieurs minutes pendant laquelle un attaquant qui a obtenu la révocation distante reste actif localement. | | |
| 47 | +| Wrapper middleware tower sur `/inbox` | Body double-pass, parsing JSON-LD partiel, fragile aux upgrades upstream | Architecturalement fragile, dupliqué dans chaque consommateur. | | |
| 48 | +| Override total de la route `/inbox` | Re-implémenter le dispatch AP côté consommateur | Anti-pattern majeur — le crate framework existe précisément pour éviter ça. | | |
| 49 | + | |
| 50 | +### Pourquoi maintenant | |
| 51 | + | |
| 52 | +- Le framework est en `experimental-fediverse`, donc on peut encore ajouter des méthodes au trait `FediverseEventObserver` sans bump majeur (cf. processus §11.1). | |
| 53 | +- Gitrust est le premier consommateur réel, donc on remonte le besoin **avant** que d'autres consommateurs aient à dupliquer la logique de cleanup. | |
| 54 | +- Le coût upstream est minimal : ~20 lignes de framework, additif, default-implementation à `{}` donc rétro-compatible. | |
| 55 | + | |
| 56 | +## API proposée | |
| 57 | + | |
| 58 | +### Trait `FediverseEventObserver` | |
| 59 | + | |
| 60 | +Ajouter une méthode default à [`crates/rustwarden-fediverse/src/observer.rs`](../../crates/rustwarden-fediverse/src/observer.rs) : | |
| 61 | + | |
| 62 | +```rust | |
| 63 | +#[async_trait] | |
| 64 | +pub trait FediverseEventObserver: Send + Sync { | |
| 65 | + // ... 6 méthodes existantes ... | |
| 66 | + | |
| 67 | + /// A `Delete{Actor}` activity has been processed by | |
| 68 | + /// [`FediverseSignalService::apply_delete_actor`]: the matching | |
| 69 | + /// `remote_identities` row has been soft-deleted (its `disabled_at` | |
| 70 | + /// is now `NOW()`). Consumers receive this hook so they can cascade | |
| 71 | + /// the deletion onto locally-managed artifacts attached to the | |
| 72 | + /// remote identity (SSH keys, remote PATs, team memberships, …). | |
| 73 | + /// | |
| 74 | + /// Invoked **after** the framework's own state mutation is | |
| 75 | + /// committed, so consumer-side lookups by `remote_identity_id` | |
| 76 | + /// observe the soft-deleted row. Idempotent semantics: the | |
| 77 | + /// framework guarantees this hook is fired **at most once per | |
| 78 | + /// actor** per inbound `Delete{Actor}` — if the row was already | |
| 79 | + /// disabled before this delivery, the hook is **not** fired (the | |
| 80 | + /// `rows_disabled` counter on the [`DeleteOutcome`] returned by | |
| 81 | + /// `apply_delete_actor` was 0). | |
| 82 | + /// | |
| 83 | + /// Errors must be swallowed by the implementation — the | |
| 84 | + /// observer is best-effort by contract (RFC 0028). | |
| 85 | + /// | |
| 86 | + /// Arguments: | |
| 87 | + /// - `domain` : lower-cased domain of the deleted actor (always | |
| 88 | + /// populated). | |
| 89 | + /// - `ap_id` : full ActivityPub `id` of the deleted actor (e.g. | |
| 90 | + /// `https://a.tld/users/alice`). | |
| 91 | + /// - `remote_identity_id` : the surrogate UUID of the | |
| 92 | + /// `remote_identities` row that was just disabled. Consumers | |
| 93 | + /// key their cleanup on this id (it is the FK pointed at by | |
| 94 | + /// `fediverse_resource_shares.remote_identity_id`, | |
| 95 | + /// `team_members.remote_identity_id`, | |
| 96 | + /// `ssh_keys.remote_identity_id` côté consommateur, etc.). | |
| 97 | + async fn on_delete_actor( | |
| 98 | + &self, | |
| 99 | + _domain: &str, | |
| 100 | + _ap_id: &str, | |
| 101 | + _remote_identity_id: Uuid, | |
| 102 | + ) { | |
| 103 | + } | |
| 104 | +} | |
| 105 | +``` | |
| 106 | + | |
| 107 | +### Call-site `FediverseSignalService::apply_delete_actor` | |
| 108 | + | |
| 109 | +Modifier [`fediverse_signal_service.rs:123-151`](../../crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs#L123) pour : | |
| 110 | +1. Garder la signature actuelle (Result + DeleteOutcome). | |
| 111 | +2. Ajouter une variante observed `apply_delete_actor_observed(db, ap_id, observer)` qui fait le travail standard puis appelle l'observer **après** le commit DB si `rows_disabled == 1`. La variante non-observed reste en place pour les call-sites qui n'ont pas besoin du hook. | |
| 112 | + | |
| 113 | +Variante observed proposée : | |
| 114 | + | |
| 115 | +```rust | |
| 116 | +pub async fn apply_delete_actor_observed( | |
| 117 | + db: &DatabaseConnection, | |
| 118 | + ap_id: &str, | |
| 119 | + observer: &dyn FediverseEventObserver, | |
| 120 | +) -> Result<DeleteOutcome, AppError> { | |
| 121 | + // 1. Fetch row (sera nécessaire pour l'id à passer à l'observer). | |
| 122 | + let model = remote_identity::Entity::find() | |
| 123 | + .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 124 | + .one(db) | |
| 125 | + .await?; | |
| 126 | + | |
| 127 | + let Some(row) = model else { | |
| 128 | + return Ok(DeleteOutcome::default()); | |
| 129 | + }; | |
| 130 | + | |
| 131 | + if row.disabled_at.is_some() { | |
| 132 | + // Already disabled — no-op + don't fire observer. | |
| 133 | + return Ok(DeleteOutcome::default()); | |
| 134 | + } | |
| 135 | + | |
| 136 | + let domain = row.domain.clone(); | |
| 137 | + let remote_identity_id = row.id; | |
| 138 | + | |
| 139 | + let mut active: remote_identity::ActiveModel = row.into(); | |
| 140 | + active.disabled_at = Set(Some(Utc::now())); | |
| 141 | + active.update(db).await?; | |
| 142 | + | |
| 143 | + // 2. Fire observer AFTER successful commit. Errors swallowed | |
| 144 | + // per FediverseEventObserver contract. | |
| 145 | + observer.on_delete_actor(&domain, ap_id, remote_identity_id).await; | |
| 146 | + | |
| 147 | + Ok(DeleteOutcome { | |
| 148 | + rows_disabled: 1, | |
| 149 | + jwts_blacklisted: 0, | |
| 150 | + }) | |
| 151 | +} | |
| 152 | +``` | |
| 153 | + | |
| 154 | +### Inbox handler | |
| 155 | + | |
| 156 | +Modifier [`inbox.rs`](../../crates/rustwarden-fediverse/src/inbox.rs) pour appeler la variante observed lors du dispatch d'un `Delete{Actor}`, en lui passant l'Extension `Arc<dyn FediverseEventObserver>` déjà résolue par le handler (cf. ligne 53 du fichier — l'observer est déjà extrait). | |
| 157 | + | |
| 158 | +## Migration guide | |
| 159 | + | |
| 160 | +Ajout additif, **zéro breaking change** : | |
| 161 | + | |
| 162 | +- Les consommateurs existants qui n'implémentent pas `on_delete_actor` reçoivent l'implémentation default (no-op) automatiquement — leur code compile inchangé. | |
| 163 | +- Les consommateurs qui veulent réagir au Delete{Actor} ajoutent une méthode à leur impl : | |
| 164 | + | |
| 165 | +```rust | |
| 166 | +#[async_trait] | |
| 167 | +impl FediverseEventObserver for MyConsumerObserver { | |
| 168 | + // ... méthodes existantes ... | |
| 169 | + | |
| 170 | + async fn on_delete_actor( | |
| 171 | + &self, | |
| 172 | + _domain: &str, | |
| 173 | + _ap_id: &str, | |
| 174 | + remote_identity_id: uuid::Uuid, | |
| 175 | + ) { | |
| 176 | + // Cleanup local artifacts attached to this remote_identity_id. | |
| 177 | + // Errors should be logged not propagated (best-effort hook). | |
| 178 | + if let Err(e) = self.cleanup_service.run_for_remote_identity(remote_identity_id).await { | |
| 179 | + tracing::warn!(error = ?e, %remote_identity_id, "delete-actor cleanup failed"); | |
| 180 | + } | |
| 181 | + } | |
| 182 | +} | |
| 183 | +``` | |
| 184 | + | |
| 185 | +### Consumer compile impact | |
| 186 | + | |
| 187 | +```markdown | |
| 188 | +None. The new method has a default empty implementation. Existing | |
| 189 | +`impl FediverseEventObserver for ...` blocks compile unchanged. | |
| 190 | + | |
| 191 | +Search-and-replace recipe (only needed when a consumer wants to | |
| 192 | +react to deletion): | |
| 193 | +```bash | |
| 194 | +rg -n "impl FediverseEventObserver for" crates/ | |
| 195 | +# → for each, add the on_delete_actor method. | |
| 196 | +``` | |
| 197 | +``` | |
| 198 | + | |
| 199 | +## Drawbacks | |
| 200 | + | |
| 201 | +- **Trait shape grandit** : 6 → 7 méthodes. Mineur, le trait est conçu comme un Observer pluggable et l'ajout est l'évolution naturelle. | |
| 202 | +- **Variante double `apply_delete_actor` / `apply_delete_actor_observed`** : duplique la surface. Alternative : remplacer la signature non-observed par la signature observed et `&NoopFediverseObserver` pour les call-sites qui n'ont pas besoin du hook. À débattre upstream — la variante double est moins disruptive pour les call-sites tiers. | |
| 203 | +- **Couplage temporel observer↔DB** : on appelle l'observer après commit. Si l'observer panique, la row est déjà disabled. C'est cohérent avec les 6 hooks existants (`on_invite_action`, etc.) qui sont eux aussi best-effort fire-and-forget. | |
| 204 | + | |
| 205 | +## Alternatives considérées | |
| 206 | + | |
| 207 | +### Alternative A — Ajouter au trait `RustwardenHooks` core | |
| 208 | + | |
| 209 | +`RustwardenHooks::on_remote_identity_disabled`. Avantage : naturel à côté de `on_user_deleted`. Inconvénient : couple `rustwarden-core` à une notion fediverse (`remote_identity_id`), ce que le framework évite intentionnellement (cf. `identity.rs` "Always compiled in core: Identity and IdentityResolver are generic abstractions that do not depend on rustwarden-fediverse"). | |
| 210 | + | |
| 211 | +→ Rejetée. | |
| 212 | + | |
| 213 | +### Alternative B — Tower middleware côté consommateur | |
| 214 | + | |
| 215 | +Wrapper `/inbox` côté gitrust qui parse le body, appelle le framework, puis fait le cleanup. Décrit en détail dans le plan §"option (b)". Faisable mais : | |
| 216 | +- Double-pass body | |
| 217 | +- Parsing JSON-LD partiel à maintenir côté consommateur | |
| 218 | +- Dupliqué dans chaque futur consommateur | |
| 219 | + | |
| 220 | +→ Rejetée comme solution long terme. Conservée en fallback temporaire si la RFC tarde upstream. | |
| 221 | + | |
| 222 | +### Alternative C — Sweep périodique côté consommateur | |
| 223 | + | |
| 224 | +C'est ce que gitrust a livré au Lot 6 inbound. Avantage : pas de RFC. Inconvénient : latence de cleanup (par défaut 5 min) durant laquelle un attaquant qui a obtenu Delete{Actor} reste actif localement. | |
| 225 | + | |
| 226 | +→ Acceptée comme defense-in-depth permanent. Cette RFC ramène la latence à <50ms en plus du sweep. | |
| 227 | + | |
| 228 | +## Score auto-évalué | |
| 229 | + | |
| 230 | +Grille [`docs/RUSTWARDEN_EVOLUTION_PROCESS.md` §4](../RUSTWARDEN_EVOLUTION_PROCESS.md) : | |
| 231 | + | |
| 232 | +| Critère | Note | Justification | | |
| 233 | +|---|---:|---| | |
| 234 | +| **Bénéfice métier** | 5/5 | Ferme un gap sécurité réel (latence de révocation post-Delete) pour tous les consommateurs. | | |
| 235 | +| **Surface de l'API** | 4/5 | +1 méthode trait + 1 variante service. Default impl no-op = zéro breaking. | | |
| 236 | +| **Coût upstream** | 5/5 | ~20 lignes framework, additif. | | |
| 237 | +| **Risque régression** | 5/5 | Aucune branche existante modifiée. Le call-site `apply_delete_actor` historique reste intact. | | |
| 238 | +| **Migration guide propre** | 5/5 | Aucun consommateur n'a besoin de modifier son code sauf s'il veut le hook. | | |
| 239 | +| **Cohérence avec le framework** | 5/5 | Pattern Observer additif, naturel après les 6 events existants. | | |
| 240 | +| **Documentation** | 5/5 | Doc complète sur la méthode + variante service. | | |
| 241 | + | |
| 242 | +**Score : 34/35** — bien au-dessus du seuil de 25/35 pour acceptation directe. | |
| 243 | + | |
| 244 | +## Workflow de push | |
| 245 | + | |
| 246 | +1. Copier ce fichier dans `<framework>/docs/rfcs/NNNN-fediverse-event-observer-delete-actor.md` (NNNN = prochain numéro upstream). | |
| 247 | +2. Ouvrir tracking issue avec labels `rfc` + `area:fediverse` + body = Summary + lien vers le fichier. | |
| 248 | +3. Si Acceptée : PR de livraison sur `rustwarden-fediverse` qui modifie le trait + le service + l'inbox handler. | |
| 249 | +4. Mettre à jour [docs/rfcs/README.md](README.md) côté gitrust avec le numéro upstream attribué. | |
| 250 | +5. Une fois livrée : côté gitrust, ajouter l'impl `on_delete_actor` à `GitrustHooks` (ou créer un observer dédié), qui appellera `FediverseOrphanCleanupService::run_for_identity(db, remote_identity_id)` (nouvelle méthode ciblée à ajouter en complément du sweep). |
A
docs/rfcs/0033-fediverse-delete-actor-hooks-unification.md
+343
-0
@@ -0,0 +1,343 @@
| 1 | +# RFC 0033 — Unifier l'API `apply_delete_actor*` derrière `DeleteActorHooks` | |
| 2 | + | |
| 3 | +- **Auteur** : EBO | |
| 4 | +- **Date** : 2026-05-17 | |
| 5 | +- **Statut** : Implemented (branche `fedivers`, 2026-05-17) | |
| 6 | +- **Crate cible** : `rustwarden-fediverse` | |
| 7 | +- **Type** : refactor additif (zero breaking change pour les consommateurs existants) | |
| 8 | +- **RFC associée** : suite directe de [RFC 0032](0032-fediverse-event-observer-delete-actor.md) | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Introduire une struct `DeleteActorHooks<'a>` et un point d'entrée canonique [`FediverseSignalService::apply_delete_actor_with_hooks`](../../crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs) qui accepte **simultanément** un observer ([RFC 0032](0032-fediverse-event-observer-delete-actor.md)) et un `ActiveTokenIterator` (RFC 0018), et faire des trois variantes existantes des wrappers fins (~3 lignes chacun) qui délèguent à cet entrypoint. | |
| 13 | + | |
| 14 | +Aucun consommateur n'a à modifier son code : les trois signatures actuelles (`apply_delete_actor`, `apply_delete_actor_observed`, `apply_delete_actor_with_iterator`) sont **préservées à l'octet près**. | |
| 15 | + | |
| 16 | +## Motivation | |
| 17 | + | |
| 18 | +### Le problème laissé par RFC 0032 | |
| 19 | + | |
| 20 | +RFC 0032 a livré `apply_delete_actor_observed(db, ap_id, observer)` pour permettre aux consommateurs de cascader le cleanup local après un `Delete{Actor}` distant. Côté JWT, RFC 0018 avait déjà livré `apply_delete_actor_with_iterator(db, ap_id, iterator)` pour blacklister les JWT actifs d'une remote identity supprimée. | |
| 21 | + | |
| 22 | +**Les deux ne se composent pas.** Un consommateur qui veut **et** purger ses artefacts locaux (SSH keys, PATs, etc.) **et** blacklister les JWT distribués à cette identity doit aujourd'hui : | |
| 23 | + | |
| 24 | +```rust | |
| 25 | +// Option 1 — double appel, double SELECT, semantics dupliquée | |
| 26 | +FediverseSignalService::apply_delete_actor_observed(db, ap_id, observer).await?; | |
| 27 | +FediverseSignalService::apply_delete_actor_with_iterator(db, ap_id, iterator).await?; | |
| 28 | +// → la deuxième appel voit `disabled_at IS NOT NULL` et retourne rows_disabled=0, | |
| 29 | +// donc le iterator n'est jamais consulté. Cassé. | |
| 30 | +``` | |
| 31 | + | |
| 32 | +```rust | |
| 33 | +// Option 2 — réimplémenter à la main côté consommateur. Anti-pattern. | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Il n'existe **aucun** chemin correct sans modifier le framework. La RFC 0032 a explicitement reconnu cette dette dans sa section *Drawbacks* : | |
| 37 | + | |
| 38 | +> **Variante double `apply_delete_actor` / `apply_delete_actor_observed`** : duplique la surface. À débattre upstream. | |
| 39 | + | |
| 40 | +### Pourquoi maintenant | |
| 41 | + | |
| 42 | +- Gitrust va consommer les deux capacités dès le Lot 6 (cleanup SSH + invalidation JWT). Sans cette RFC, gitrust devra copier-coller la logique d'upsert/idempotence côté consommateur. | |
| 43 | +- La crate fediverse est en `experimental-fediverse`, donc on peut encore restructurer les exports sans bump majeur. | |
| 44 | +- La fenêtre est étroite : chaque nouveau consommateur qui se cale sur l'API actuelle ancrera la duplication. | |
| 45 | + | |
| 46 | +### État actuel de l'API service | |
| 47 | + | |
| 48 | +| Fonction | Observer ? | Iterator ? | Idempotent ? | Sémantique | | |
| 49 | +|---|:---:|:---:|:---:|---| | |
| 50 | +| `apply_delete_actor` | ❌ | ❌ | ✅ | Flip `disabled_at` | | |
| 51 | +| `apply_delete_actor_observed` (RFC 0032) | ✅ | ❌ | ✅ | + `on_delete_actor` | | |
| 52 | +| `apply_delete_actor_with_iterator` (RFC 0018) | ❌ | ✅ | ✅ | + blacklist JWTs | | |
| 53 | +| *(manquant)* | ✅ | ✅ | ✅ | tout combiné | | |
| 54 | + | |
| 55 | +La 4ᵉ ligne est ce qui manque. La création d'une fonction `apply_delete_actor_with_iterator_and_observer` (variante combinée) est la solution naïve — refusée plus bas. | |
| 56 | + | |
| 57 | +## API proposée | |
| 58 | + | |
| 59 | +### 1. Nouvelle struct `DeleteActorHooks` | |
| 60 | + | |
| 61 | +```rust | |
| 62 | +/// Hooks branchables sur une suppression d'actor distante. | |
| 63 | +/// | |
| 64 | +/// Tous les champs sont `Option` avec un default `None` (no-op). Le | |
| 65 | +/// pattern recommandé d'instanciation est le struct literal avec | |
| 66 | +/// `..Default::default()` pour garantir la rétro-compatibilité quand | |
| 67 | +/// de nouveaux hooks seront ajoutés à cette struct dans des RFCs | |
| 68 | +/// futures : | |
| 69 | +/// | |
| 70 | +/// ```rust,ignore | |
| 71 | +/// let hooks = DeleteActorHooks { | |
| 72 | +/// observer: Some(&my_observer), | |
| 73 | +/// ..Default::default() | |
| 74 | +/// }; | |
| 75 | +/// ``` | |
| 76 | +/// | |
| 77 | +/// La struct **n'est pas** `#[non_exhaustive]` : la pattern | |
| 78 | +/// `..Default::default()` suffit à protéger les call-sites contre | |
| 79 | +/// l'ajout de nouveaux champs, sans bloquer la construction par struct | |
| 80 | +/// literal dans les crates consommatrices. | |
| 81 | +#[derive(Default)] | |
| 82 | +pub struct DeleteActorHooks<'a> { | |
| 83 | + /// Observer notifié post-commit (cf. RFC 0032). `None` ⇒ aucun | |
| 84 | + /// hook ne tire. | |
| 85 | + pub observer: Option<&'a dyn FediverseEventObserver>, | |
| 86 | + /// Énumérateur des JWT actifs à blacklister (cf. RFC 0018). | |
| 87 | + /// `None` ⇒ `jwts_blacklisted = 0`. | |
| 88 | + pub iterator: Option<&'a dyn ActiveTokenIterator>, | |
| 89 | +} | |
| 90 | +``` | |
| 91 | + | |
| 92 | +### 2. Nouveau point d'entrée canonique | |
| 93 | + | |
| 94 | +```rust | |
| 95 | +impl FediverseSignalService { | |
| 96 | + /// Entrypoint canonique pour appliquer un `Delete{Actor}` distant. | |
| 97 | + /// Combine la sémantique de toutes les variantes historiques : | |
| 98 | + /// | |
| 99 | + /// 1. Flip `remote_identities.disabled_at` → `NOW()` (idempotent). | |
| 100 | + /// 2. Si `hooks.iterator.is_some()` : blacklist tous les JWT actifs | |
| 101 | + /// rapportés par l'iterator pour la `remote_identity_id` | |
| 102 | + /// déterminée par le flip. | |
| 103 | + /// 3. Si `hooks.observer.is_some()` ET `rows_disabled == 1` : fire | |
| 104 | + /// `on_delete_actor(domain, ap_id, remote_identity_id)` | |
| 105 | + /// **après** que l'étape 2 ait été commitée. | |
| 106 | + /// | |
| 107 | + /// Idempotence : les étapes 2 et 3 ne s'exécutent que sur une | |
| 108 | + /// transition active → désactivée. Une re-livraison du même | |
| 109 | + /// `Delete{Actor}` ne re-blackliste pas et ne re-fire pas le | |
| 110 | + /// hook. | |
| 111 | + pub async fn apply_delete_actor_with_hooks( | |
| 112 | + db: &DatabaseConnection, | |
| 113 | + ap_id: &str, | |
| 114 | + hooks: DeleteActorHooks<'_>, | |
| 115 | + ) -> Result<DeleteOutcome, AppError> { | |
| 116 | + // ... voir Implementation Sketch ... | |
| 117 | + } | |
| 118 | +} | |
| 119 | +``` | |
| 120 | + | |
| 121 | +### 3. Wrappers de compatibilité (signatures inchangées) | |
| 122 | + | |
| 123 | +```rust | |
| 124 | +impl FediverseSignalService { | |
| 125 | + /// Wrapper de compatibilité. Préférer | |
| 126 | + /// [`Self::apply_delete_actor_with_hooks`] pour le code neuf. | |
| 127 | + pub async fn apply_delete_actor( | |
| 128 | + db: &DatabaseConnection, | |
| 129 | + ap_id: &str, | |
| 130 | + ) -> Result<DeleteOutcome, AppError> { | |
| 131 | + Self::apply_delete_actor_with_hooks(db, ap_id, DeleteActorHooks::default()).await | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Wrapper de compatibilité. Préférer | |
| 135 | + /// [`Self::apply_delete_actor_with_hooks`] pour le code neuf. | |
| 136 | + pub async fn apply_delete_actor_observed( | |
| 137 | + db: &DatabaseConnection, | |
| 138 | + ap_id: &str, | |
| 139 | + observer: &dyn FediverseEventObserver, | |
| 140 | + ) -> Result<DeleteOutcome, AppError> { | |
| 141 | + Self::apply_delete_actor_with_hooks( | |
| 142 | + db, | |
| 143 | + ap_id, | |
| 144 | + DeleteActorHooks { observer: Some(observer), ..Default::default() }, | |
| 145 | + ) | |
| 146 | + .await | |
| 147 | + } | |
| 148 | + | |
| 149 | + /// Wrapper de compatibilité. Préférer | |
| 150 | + /// [`Self::apply_delete_actor_with_hooks`] pour le code neuf. | |
| 151 | + pub async fn apply_delete_actor_with_iterator( | |
| 152 | + db: &DatabaseConnection, | |
| 153 | + ap_id: &str, | |
| 154 | + iterator: &dyn ActiveTokenIterator, | |
| 155 | + ) -> Result<DeleteOutcome, AppError> { | |
| 156 | + Self::apply_delete_actor_with_hooks( | |
| 157 | + db, | |
| 158 | + ap_id, | |
| 159 | + DeleteActorHooks { iterator: Some(iterator), ..Default::default() }, | |
| 160 | + ) | |
| 161 | + .await | |
| 162 | + } | |
| 163 | +} | |
| 164 | +``` | |
| 165 | + | |
| 166 | +### 4. Migration des call-sites internes | |
| 167 | + | |
| 168 | +Le dispatcher inbox ([`crates/rustwarden-fediverse/src/inbox.rs:202`](../../crates/rustwarden-fediverse/src/inbox.rs)) passe au nouvel entrypoint : | |
| 169 | + | |
| 170 | +```rust | |
| 171 | +// Avant | |
| 172 | +FediverseSignalService::apply_delete_actor_observed(db, &ap_id, observer).await?; | |
| 173 | + | |
| 174 | +// Après | |
| 175 | +FediverseSignalService::apply_delete_actor_with_hooks( | |
| 176 | + db, | |
| 177 | + &ap_id, | |
| 178 | + DeleteActorHooks { observer: Some(observer), ..Default::default() }, | |
| 179 | +).await?; | |
| 180 | +``` | |
| 181 | + | |
| 182 | +L'iterator n'est pas câblé au handler inbox dans cette RFC (relève d'une RFC dédiée pour exposer l'`ActiveTokenIterator` via `Extension` ou builder). | |
| 183 | + | |
| 184 | +## Implementation sketch | |
| 185 | + | |
| 186 | +Le corps unifié reprend la logique existante de `apply_delete_actor_observed`, en intercalant l'étape iterator : | |
| 187 | + | |
| 188 | +```rust | |
| 189 | +pub async fn apply_delete_actor_with_hooks( | |
| 190 | + db: &DatabaseConnection, | |
| 191 | + ap_id: &str, | |
| 192 | + hooks: DeleteActorHooks<'_>, | |
| 193 | +) -> Result<DeleteOutcome, AppError> { | |
| 194 | + let Some(row) = remote_identity::Entity::find() | |
| 195 | + .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 196 | + .one(db) | |
| 197 | + .await? | |
| 198 | + else { | |
| 199 | + return Ok(DeleteOutcome::default()); | |
| 200 | + }; | |
| 201 | + | |
| 202 | + if row.disabled_at.is_some() { | |
| 203 | + return Ok(DeleteOutcome { rows_disabled: 0, jwts_blacklisted: 0 }); | |
| 204 | + } | |
| 205 | + | |
| 206 | + let domain = row.domain.clone(); | |
| 207 | + let remote_identity_id = row.id; | |
| 208 | + | |
| 209 | + let mut active: remote_identity::ActiveModel = row.into(); | |
| 210 | + active.disabled_at = Set(Some(Utc::now())); | |
| 211 | + active.update(db).await?; | |
| 212 | + | |
| 213 | + let jwts_blacklisted = if let Some(it) = hooks.iterator { | |
| 214 | + JwtBlacklistService::blacklist_by_remote_identity_id(db, remote_identity_id, it).await? | |
| 215 | + } else { | |
| 216 | + 0 | |
| 217 | + }; | |
| 218 | + | |
| 219 | + if let Some(observer) = hooks.observer { | |
| 220 | + observer.on_delete_actor(&domain, ap_id, remote_identity_id).await; | |
| 221 | + } | |
| 222 | + | |
| 223 | + Ok(DeleteOutcome { | |
| 224 | + rows_disabled: 1, | |
| 225 | + jwts_blacklisted, | |
| 226 | + }) | |
| 227 | +} | |
| 228 | +``` | |
| 229 | + | |
| 230 | +**~25 lignes** au total. Les trois wrappers font ~5 lignes chacun. Le test suite RFC 0032 + RFC 0018 s'applique inchangé puisque les wrappers délèguent. **2 tests nouveaux** suffisent à couvrir le chemin combiné. | |
| 231 | + | |
| 232 | +## Analyse de compatibilité | |
| 233 | + | |
| 234 | +| Surface | Avant | Après | Breaking ? | | |
| 235 | +|---|---|---|:---:| | |
| 236 | +| `apply_delete_actor(db, ap_id)` | Existe | Identique (wrapper) | ❌ | | |
| 237 | +| `apply_delete_actor_observed(db, ap_id, observer)` | Existe | Identique (wrapper) | ❌ | | |
| 238 | +| `apply_delete_actor_with_iterator(db, ap_id, iter)` | Existe | Identique (wrapper) | ❌ | | |
| 239 | +| `apply_delete_actor_with_hooks(...)` | N'existe pas | Nouveau | ➕ additif | | |
| 240 | +| `DeleteActorHooks<'a>` | N'existe pas | Nouvelle struct publique | ➕ additif | | |
| 241 | +| Sémantique (idempotence, ordering, no-fire on no-row) | RFC 0032 | Inchangée | ❌ | | |
| 242 | + | |
| 243 | +**Aucun changement de comportement observable** pour les call-sites existants. Le hash MD5 du `DeleteOutcome` retourné pour un input donné est identique avant/après RFC 0033. | |
| 244 | + | |
| 245 | +## Testing plan | |
| 246 | + | |
| 247 | +### Tests reconduits (inchangés) | |
| 248 | + | |
| 249 | +Les 3 tests `apply_delete_actor_observed_*` + le test `apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero` continuent de passer puisque les wrappers délèguent au nouveau core. | |
| 250 | + | |
| 251 | +### Tests nouveaux | |
| 252 | + | |
| 253 | +1. **`apply_delete_actor_with_hooks_combines_observer_and_iterator`** | |
| 254 | + Iterator fournit 2 JTI, observer compte 1 call. Assert `rows_disabled=1`, `jwts_blacklisted=2`, observer reçoit le `(domain, ap_id, id)` correct. | |
| 255 | + | |
| 256 | +2. **`apply_delete_actor_with_hooks_skips_observer_and_iterator_when_already_disabled`** | |
| 257 | + Row déjà disabled : ni observer ni iterator ne sont consultés. | |
| 258 | + | |
| 259 | +3. **`delete_actor_hooks_default_is_all_none`** | |
| 260 | + Trivial : `DeleteActorHooks::default()` donne `observer=None`, `iterator=None`. | |
| 261 | + | |
| 262 | +### Tests à supprimer | |
| 263 | + | |
| 264 | +Aucun. Les anciens tests valident toujours le contrat public des wrappers. | |
| 265 | + | |
| 266 | +## Non-goals | |
| 267 | + | |
| 268 | +- **Ne touche pas** au trait [`ActiveTokenIterator`](../../crates/rustwarden-core/src/services/jwt_blacklist_service.rs) ni à sa sémantique. | |
| 269 | +- **Ne touche pas** au trait [`FediverseEventObserver`](../../crates/rustwarden-fediverse/src/observer.rs) ni à ses 7 méthodes. | |
| 270 | +- **Ne câble pas** l'iterator au handler inbox `/inbox` — c'est une décision orthogonale (qui exposerait l'`ActiveTokenIterator` via builder / Extension). Sujet de RFC séparée. | |
| 271 | +- **Pas de deprecation** des trois wrappers : ils restent first-class. Un `#[deprecated]` éventuel relèvera d'une RFC future après période d'observation. | |
| 272 | +- **Pas de builder pattern** : la struct literal avec `..Default::default()` est suffisamment ergonomique pour le besoin actuel. Un builder peut être ajouté ultérieurement comme convenience additive. | |
| 273 | + | |
| 274 | +## Alternatives considérées | |
| 275 | + | |
| 276 | +### Alternative A — 4ᵉ variante `apply_delete_actor_with_iterator_and_observer` | |
| 277 | + | |
| 278 | +Ajouter une fonction nommée par concaténation des hooks activés. | |
| 279 | + | |
| 280 | +- **Coût** : 1 nouvelle fonction de ~30 lignes. | |
| 281 | +- **Problème** : combinatoire. Si une RFC future ajoute un 3ᵉ hook (audit log, métriques), on a 2³ = 8 variantes possibles à nommer/maintenir. | |
| 282 | + | |
| 283 | +→ **Rejetée**. Ne scale pas. | |
| 284 | + | |
| 285 | +### Alternative B — Builder pattern fluide | |
| 286 | + | |
| 287 | +```rust | |
| 288 | +DeleteActorHooks::new() | |
| 289 | + .with_observer(&my_obs) | |
| 290 | + .with_iterator(&my_it) | |
| 291 | +``` | |
| 292 | + | |
| 293 | +- **Coût** : ~4 méthodes supplémentaires. | |
| 294 | +- **Avantage** : un peu plus self-documenting. | |
| 295 | +- **Inconvénient** : double l'API surface sans apporter d'expressivité décisive vs le struct literal. | |
| 296 | + | |
| 297 | +→ **Reportée**. Peut être ajoutée plus tard comme couche additive sans cassure. | |
| 298 | + | |
| 299 | +### Alternative C — Remplacer les 3 wrappers par `#[deprecated]` pointant vers `_with_hooks` | |
| 300 | + | |
| 301 | +- **Avantage** : pousse activement les consommateurs vers la nouvelle API ; surface canonique unique. | |
| 302 | +- **Inconvénient** : génère des warnings dans tous les consommateurs existants (gitrust en tête) **dès cette RFC**, alors qu'aucune fonctionnalité ne le justifie. Force une migration synchrone côté consommateurs sur leur prochain `cargo update`. | |
| 303 | + | |
| 304 | +→ **Reportée**. La deprecation pourra être faite dans une RFC ultérieure après que le `_with_hooks` ait fait ses preuves côté consommateurs. | |
| 305 | + | |
| 306 | +### Alternative D — Ne rien faire (statu quo post-RFC 0032) | |
| 307 | + | |
| 308 | +- **Coût immédiat** : 0. | |
| 309 | +- **Coût caché** : chaque consommateur qui veut observer + iterator dupliquera la logique de flip/idempotence, perdant l'atomicité de la transition et risquant des bugs subtils (double-blacklist, race conditions). | |
| 310 | + | |
| 311 | +→ **Rejetée**. La dette est connue et chaque jour qui passe alourdit la migration future. | |
| 312 | + | |
| 313 | +## Score auto-évalué | |
| 314 | + | |
| 315 | +Grille [`docs/RUSTWARDEN_EVOLUTION_PROCESS.md` §4](../RUSTWARDEN_EVOLUTION_PROCESS.md) : | |
| 316 | + | |
| 317 | +| Critère | Note | Justification | | |
| 318 | +|---|---:|---| | |
| 319 | +| **Généralité** | 5/5 | Le struct `DeleteActorHooks` accommode tout futur hook sans nouvelle variante de fonction. | | |
| 320 | +| **Stabilité API** | 5/5 | Strictement additif. Les 3 signatures publiques existantes sont préservées octet pour octet. | | |
| 321 | +| **Performance** | 5/5 | Aucun overhead : les `Option<&dyn Trait>` sont des pointers nullables, branchés sans allocation. | | |
| 322 | +| **Sécurité** | 4/5 | Ferme un trou de composabilité qui forçait certains consommateurs à dupliquer la logique d'idempotence → risque de bug réduit. Pas de nouvelle surface d'attaque. | | |
| 323 | +| **Complexité d'implémentation** | 5/5 | ~50 lignes net (core + 3 wrappers + struct). Pas d'`unsafe`, pas de macro custom. | | |
| 324 | +| **Charge de maintenance** | 5/5 | Une seule impl à maintenir. Les wrappers sont des 1-liners qui n'ont rien à diverger. | | |
| 325 | +| **Documentation** | 5/5 | Doc complète sur la struct, l'entrypoint, et chaque wrapper avec pointeur vers le canonical. | | |
| 326 | + | |
| 327 | +**Score : 34/35** — au-dessus du seuil de 25/35 pour acceptation directe. | |
| 328 | + | |
| 329 | +## Open questions | |
| 330 | + | |
| 331 | +1. **Faut-il rendre le wrapper `apply_delete_actor` (sans hooks) `#[doc(hidden)]` ?** Le plus utilisé historiquement mais le moins utile post-RFC 0033. Décision : non, garder visible — utile pour les call-sites qui n'ont vraiment besoin de rien (ex. réplication offline depuis un dump). | |
| 332 | + | |
| 333 | +2. **Faut-il ajouter `#[must_use]` sur `DeleteOutcome` ?** Indépendant de cette RFC, mais à considérer en hardening connexe. | |
| 334 | + | |
| 335 | +3. **`DeleteActorHooks` mérite-t-il un `Debug` derive ?** Les `dyn Trait` interdisent le `Debug` direct. On peut imprimer la présence/absence via une impl manuelle. À voir au moment de l'implémentation si un test ou un log le réclame. | |
| 336 | + | |
| 337 | +## Workflow | |
| 338 | + | |
| 339 | +1. PR `feat(fediverse): unify apply_delete_actor variants behind DeleteActorHooks` sur la branche `fedivers`. | |
| 340 | +2. Quality gates : `cargo build`, `cargo test -p rustwarden-fediverse`, `cargo clippy --all-targets -D warnings`. | |
| 341 | +3. Mettre à jour [docs/SERVICES_REFERENCE.md §21](../SERVICES_REFERENCE.md) avec la nouvelle signature canonique. | |
| 342 | +4. Mettre à jour [docs/rfcs/README.md](README.md) avec l'entrée 0033. | |
| 343 | +5. Statut → `Accepted` → `Implemented` après merge. |
GitRust