RFC 0032 — Ajouter on_delete_actor à FediverseEventObserver
Ancien numéro local gitrust : RFC 0004. Le numéro upstream est 0032.
- Auteur : EBO (gitrust)
- Date : 2026-05-17
- Statut : Implemented (2026-10-01)
- Signature livrée :
on_delete_actor(domain, ap_id, remote_identity_id)— pasrows_disabled. Appelée depuisapply_delete_actor. - Crate cible :
rustwarden-fediverse - Type : extension trait additive (pas de breaking change pour les consommateurs existants — ajout d'une méthode
defaultau traitFediverseEventObserver)
Summary
Ajouter une 6ᵉ méthode on_delete_actor(domain, ap_id, rows_disabled) au trait FediverseEventObserver et l'invoquer depuis FediverseSignalService::apply_delete_actor après que disabled_at ait été flippé.
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.
Motivation
Contexte
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).
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.
Problème pour gitrust (~Lot 6 du plan fédération)
Plan d'intégration fediverse §555-562 :
Une remote_identity passe en
disabled_at IS NOT NULLquand son instance source envoie unDelete{Actor}. Cette désactivation doit cascader sur les artefacts locaux gitrust pour qu'alice ne puisse plus opérer.
Gitrust a 3 axes de cleanup :
- SSH keys : table gitrust
ssh_keys.remote_identity_id— pas de FK CASCADE possible (cross-tracker ordering interdit, cf. migrationm20260520_000025) - Remote PATs : table framework
fediverse_personal_access_tokens—RemotePersonalAccessTokenService::revoke_all_for_remote_identityexiste déjà mais n'est pas appelé automatiquement - Memberships / shares : conservés volontairement pour audit, mais l'admin peut vouloir scripter leur révocation
État du framework actuel
RustwardenHooks : 8 méthodes lifecycle (user/resource/team), aucune sur les remote identities.
FediverseEventObserver : 6 méthodes (signature_failure, replay_attempt, fetch_ssrf, inbox_abuse, auth_challenge, invite_action). Aucune sur delete_actor.
→ Le consommateur n'a aucun hook propre pour réagir au Delete{Actor}. Les options actuelles sont toutes contournées :
| Approche | Coût | Pourquoi insatisfaisante |
|---|---|---|
| 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. |
Wrapper middleware tower sur /inbox | Body double-pass, parsing JSON-LD partiel, fragile aux upgrades upstream | Architecturalement fragile, dupliqué dans chaque consommateur. |
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. |
Pourquoi maintenant
- Le framework est en
experimental-fediverse, donc on peut encore ajouter des méthodes au traitFediverseEventObserversans bump majeur (cf. processus §11.1). - Gitrust est le premier consommateur réel, donc on remonte le besoin avant que d'autres consommateurs aient à dupliquer la logique de cleanup.
- Le coût upstream est minimal : ~20 lignes de framework, additif, default-implementation à
{}donc rétro-compatible.
API proposée
Trait FediverseEventObserver
Ajouter une méthode default à crates/rustwarden-fediverse/src/observer.rs :
#[async_trait] pub trait FediverseEventObserver: Send + Sync { // ... 6 méthodes existantes ... /// A `Delete{Actor}` activity has been processed by /// [`FediverseSignalService::apply_delete_actor`]: the matching /// `remote_identities` row has been soft-deleted (its `disabled_at` /// is now `NOW()`). Consumers receive this hook so they can cascade /// the deletion onto locally-managed artifacts attached to the /// remote identity (SSH keys, remote PATs, team memberships, …). /// /// Invoked **after** the framework's own state mutation is /// committed, so consumer-side lookups by `remote_identity_id` /// observe the soft-deleted row. Idempotent semantics: the /// framework guarantees this hook is fired **at most once per /// actor** per inbound `Delete{Actor}` — if the row was already /// disabled before this delivery, the hook is **not** fired (the /// `rows_disabled` counter on the [`DeleteOutcome`] returned by /// `apply_delete_actor` was 0). /// /// Errors must be swallowed by the implementation — the /// observer is best-effort by contract (RFC 0028). /// /// Arguments: /// - `domain` : lower-cased domain of the deleted actor (always /// populated). /// - `ap_id` : full ActivityPub `id` of the deleted actor (e.g. /// `https://a.tld/users/alice`). /// - `remote_identity_id` : the surrogate UUID of the /// `remote_identities` row that was just disabled. Consumers /// key their cleanup on this id (it is the FK pointed at by /// `fediverse_resource_shares.remote_identity_id`, /// `team_members.remote_identity_id`, /// `ssh_keys.remote_identity_id` côté consommateur, etc.). async fn on_delete_actor( &self, _domain: &str, _ap_id: &str, _remote_identity_id: Uuid, ) { } }
Call-site FediverseSignalService::apply_delete_actor
Modifier fediverse_signal_service.rs:123-151 pour :
- Garder la signature actuelle (Result + DeleteOutcome).
- 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 sirows_disabled == 1. La variante non-observed reste en place pour les call-sites qui n'ont pas besoin du hook.
Variante observed proposée :
pub async fn apply_delete_actor_observed( db: &DatabaseConnection, ap_id: &str, observer: &dyn FediverseEventObserver, ) -> Result<DeleteOutcome, AppError> { // 1. Fetch row (sera nécessaire pour l'id à passer à l'observer). let model = remote_identity::Entity::find() .filter(remote_identity::Column::ApId.eq(ap_id)) .one(db) .await?; let Some(row) = model else { return Ok(DeleteOutcome::default()); }; if row.disabled_at.is_some() { // Already disabled — no-op + don't fire observer. return Ok(DeleteOutcome::default()); } let domain = row.domain.clone(); let remote_identity_id = row.id; let mut active: remote_identity::ActiveModel = row.into(); active.disabled_at = Set(Some(Utc::now())); active.update(db).await?; // 2. Fire observer AFTER successful commit. Errors swallowed // per FediverseEventObserver contract. observer.on_delete_actor(&domain, ap_id, remote_identity_id).await; Ok(DeleteOutcome { rows_disabled: 1, jwts_blacklisted: 0, }) }
Inbox handler
Modifier 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).
Migration guide
Ajout additif, zéro breaking change :
- Les consommateurs existants qui n'implémentent pas
on_delete_actorreçoivent l'implémentation default (no-op) automatiquement — leur code compile inchangé. - Les consommateurs qui veulent réagir au Delete{Actor} ajoutent une méthode à leur impl :
#[async_trait] impl FediverseEventObserver for MyConsumerObserver { // ... méthodes existantes ... async fn on_delete_actor( &self, _domain: &str, _ap_id: &str, remote_identity_id: uuid::Uuid, ) { // Cleanup local artifacts attached to this remote_identity_id. // Errors should be logged not propagated (best-effort hook). if let Err(e) = self.cleanup_service.run_for_remote_identity(remote_identity_id).await { tracing::warn!(error = ?e, %remote_identity_id, "delete-actor cleanup failed"); } } }
Consumer compile impact
None. The new method has a default empty implementation. Existing `impl FediverseEventObserver for ...` blocks compile unchanged. Search-and-replace recipe (only needed when a consumer wants to react to deletion): ```bash rg -n "impl FediverseEventObserver for" crates/ # → for each, add the on_delete_actor method.
## Drawbacks - **Trait shape grandit** : 6 → 7 méthodes. Mineur, le trait est conçu comme un Observer pluggable et l'ajout est l'évolution naturelle. - **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. - **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. ## Alternatives considérées ### Alternative A — Ajouter au trait `RustwardenHooks` core `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"). → Rejetée. ### Alternative B — Tower middleware côté consommateur 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 : - Double-pass body - Parsing JSON-LD partiel à maintenir côté consommateur - Dupliqué dans chaque futur consommateur → Rejetée comme solution long terme. Conservée en fallback temporaire si la RFC tarde upstream. ### Alternative C — Sweep périodique côté consommateur 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. → Acceptée comme defense-in-depth permanent. Cette RFC ramène la latence à <50ms en plus du sweep. ## Score auto-évalué Grille [`docs/RUSTWARDEN_EVOLUTION_PROCESS.md` §4](../RUSTWARDEN_EVOLUTION_PROCESS.md) : | Critère | Note | Justification | |---|---:|---| | **Bénéfice métier** | 5/5 | Ferme un gap sécurité réel (latence de révocation post-Delete) pour tous les consommateurs. | | **Surface de l'API** | 4/5 | +1 méthode trait + 1 variante service. Default impl no-op = zéro breaking. | | **Coût upstream** | 5/5 | ~20 lignes framework, additif. | | **Risque régression** | 5/5 | Aucune branche existante modifiée. Le call-site `apply_delete_actor` historique reste intact. | | **Migration guide propre** | 5/5 | Aucun consommateur n'a besoin de modifier son code sauf s'il veut le hook. | | **Cohérence avec le framework** | 5/5 | Pattern Observer additif, naturel après les 6 events existants. | | **Documentation** | 5/5 | Doc complète sur la méthode + variante service. | **Score : 34/35** — bien au-dessus du seuil de 25/35 pour acceptation directe. ## Workflow de push 1. Copier ce fichier dans `<framework>/docs/rfcs/NNNN-fediverse-event-observer-delete-actor.md` (NNNN = prochain numéro upstream). 2. Ouvrir tracking issue avec labels `rfc` + `area:fediverse` + body = Summary + lien vers le fichier. 3. Si Acceptée : PR de livraison sur `rustwarden-fediverse` qui modifie le trait + le service + l'inbox handler. 4. Mettre à jour [docs/rfcs/README.md](README.md) côté gitrust avec le numéro upstream attribué. 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).
GitRust