0032-fediverse-event-observer-delete-actor.md 13547 octets

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) — pas rows_disabled. Appelée depuis apply_delete_actor.
  • Crate cible : rustwarden-fediverse
  • Type : extension trait additive (pas de breaking change pour les consommateurs existants — ajout d'une méthode default au trait FediverseEventObserver)

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 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.

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. migration m20260520_000025)
  • Remote PATs : table framework fediverse_personal_access_tokens — RemotePersonalAccessTokenService::revoke_all_for_remote_identity existe 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 :

ApprocheCoûtPourquoi insatisfaisante
Sweep périodique côté consommateur~5 min de latence cleanupUne 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 /inboxBody double-pass, parsing JSON-LD partiel, fragile aux upgrades upstreamArchitecturalement fragile, dupliqué dans chaque consommateur.
Override total de la route /inboxRe-implémenter le dispatch AP côté consommateurAnti-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 trait FediverseEventObserver sans 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 :

  1. Garder la signature actuelle (Result + DeleteOutcome).
  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.

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_actor reç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).