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.