0018-fediverse-tech-debt-closure.md 9958 octets

RFC 0018 — Fediverse: clôture dette technique (PK relax + JWT blacklist par remote identity)

  • Auteur : EBO (rustwarden)
  • Date : 2026-05-10
  • Statut : Accepted (auto-revue mainteneur unique 2026-05-10, score 26/35)
  • Apps consommatrices concernées : toute app fediverse en production qui veut (a) que les memberships remote soient réellement persistés en DB, (b) que la suppression d'un acteur distant invalide ses sessions actives.
  • Plan associé : Phase 5 — production polish.
  • Dépendances : RFC 0008 (sibling FK), RFC 0009 (invitations), RFC 0010 (signal service).

Summary

Deux changements techniques pour clôturer les dettes documentées au fil des RFCs précédentes :

  1. Relax la PK composite team_members(team_id, user_id) vers (team_id, user_id, remote_identity_id) avec user_id nullable + CHECK XOR. Permet à team_service::add_member_identity(Identity::Remote(_)) de réellement persister une row, et lève le #[ignore] sur le test d'intégration de RFC 0009.
  2. Ajouter JwtBlacklistService::blacklist_by_remote_identity_id(...) qui flag tous les tokens actifs d'une identité distante. Branche FediverseSignalService::apply_delete_actor pour qu'il fasse passer DeleteOutcome::jwts_blacklisted à un compteur réel (au lieu de toujours 0).

Motivation

  • Sans le PK relax, RFC 0009 promet une API add_member_identity qui plante en prod sur les rows distantes (fk_team_members_user_id rejette Uuid::nil()). Le #[ignore] du test cache le bug en CI mais l'API publique reste fausse.
  • Sans la nouvelle méthode JWT, la promesse Phase 4 « Delete{Actor} → sessions invalidées en moins de 30 s » est tenue à 50 % : la row remote_identities est marquée disabled_at mais les JWT déjà émis restent valides jusqu'à expiration.

Non-goals

  • Pas de refonte du blocklist storage — on réutilise la table jwt_blacklist existante.
  • Pas de changement de l'API add_member locale — l'API legacy continue de fonctionner inchangée.
  • Pas de purge proactive du cache de session côté app — les apps qui maintiennent un cache distinct doivent l'invalider via leur propre hook (déjà disponible).

API proposée

Migration m20260510_000016_relax_team_members_pk

-- 1. Drop the existing composite PK so user_id can be nullable.
ALTER TABLE team_members DROP CONSTRAINT team_members_pkey;

-- 2. user_id becomes nullable (remote-only rows have user_id IS NULL).
ALTER TABLE team_members ALTER COLUMN user_id DROP NOT NULL;

-- 3. CHECK XOR: at least one of (user_id, remote_identity_id) is set.
ALTER TABLE team_members ADD CONSTRAINT chk_team_members_principal
    CHECK ((user_id IS NOT NULL)::int + (remote_identity_id IS NOT NULL)::int = 1);

-- 4. Synthetic surrogate id for the new PK (preserves SeaORM
--    composite-PK lookups via the existing partial unique indexes).
ALTER TABLE team_members ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid();
ALTER TABLE team_members ADD PRIMARY KEY (id);

-- 5. Preserve uniqueness for local rows (replaces the old composite PK
--    semantics for `user_id IS NOT NULL`).
CREATE UNIQUE INDEX team_members_local_unique
    ON team_members (team_id, user_id)
    WHERE user_id IS NOT NULL;
-- (Index `team_members_remote_unique` already exists from RFC 0009.)

team_member::Model gagne le champ pub id: Uuid et le primary_key SeaORM bascule sur ce champ. (team_id, user_id) cesse d'être la PK SeaORM ; les lookups Entity::find_by_id((team_id, user_id)) existants doivent migrer vers Entity::find().filter(...). La RFC liste les call-sites concernés et fournit le diff complet.

Méthode JwtBlacklistService::blacklist_by_remote_identity_id

impl JwtBlacklistService {
    /// Blacklist every non-expired token whose `claims.remote_id`
    /// matches the given identity. Returns the number of rows
    /// inserted (0 when no active token exists).
    pub async fn blacklist_by_remote_identity_id(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
    ) -> Result<u64, AppError>;
}

L'algorithme :

  1. Pour chaque token actif (table jwt_blacklist ne stocke pas les actifs — il faudra parcourir les sessions ou la table refresh_tokens).
  2. Décoder Claims (sans valider la signature, payload uniquement).
  3. Si claims.remote_id == Some(remote_identity_id) → ajouter à jwt_blacklist.

Note importante : la table jwt_blacklist n'a actuellement aucune source canonique des "tokens actifs". Si rustwarden ne maintient pas une table d'audit des émissions, on ne peut pas énumérer rétroactivement. La nouvelle méthode délègue à un trait ActiveTokenIterator que les apps consommatrices implémentent (par défaut, une impl NoopActiveTokenIterator retourne 0 — sémantique : « pas pire que l'état actuel »).

#[async_trait]
pub trait ActiveTokenIterator: Send + Sync {
    /// Yield every active (non-expired, non-blacklisted) JWT for the
    /// given remote identity. The iterator returns the raw token
    /// string so the blacklist service can hash it consistently.
    async fn active_tokens_for_remote(
        &self,
        remote_identity_id: Uuid,
    ) -> Result<Vec<String>, AppError>;
}

pub struct NoopActiveTokenIterator;

apply_delete_actor est mis à jour pour accepter un Arc<dyn ActiveTokenIterator> (param optionnel via une signature surchargée — apply_delete_actor reste, apply_delete_actor_with_iterator est ajoutée).

Mise à jour du wiring

team_service::add_member_identity(Identity::Remote(_)) :

  • Insère sans Uuid::nil() (user_id reste None).
  • Le test d'intégration rfc0009_add_member_identity_remote_writes_remote_identity_id_column perd son #[ignore].

Analyse de compatibilité

  • Breaking sur team_member::Model (PK change). Les apps qui font Entity::find_by_id((team_id, user_id)) doivent migrer. Mitigation : tous les call-sites internes sont listés et migrés dans la même PR.
  • Migration up/down réversible (snapshots SQL clairs).
  • JwtBlacklistService reste rétrocompatible — nouvelle méthode purement additive.

Alternatives considérées

Alternative A (recommandée) — PK surrogate id + CHECK XOR + nouvelle méthode JWT

Pros : ferme les deux dettes en une RFC ; pattern propre.

Cons : breaking sur Model. Mitigation par PR-bundle.

Alternative B — Garder la PK composite et stocker un sentinel user_id = nil

Cons : viole la FK users.id ; requiert de désactiver la FK ; pollue les requêtes locales.

Rejeté.

Alternative C — Table séparée team_members_remote

Cons : duplique le service ; complexifie chaque endpoint listing.

Rejeté.

Alternative D — Ne rien faire

Cons : RFC 0009 reste fausse en prod, RFC 0010 promet l'invalidation et délivre 0.

Rejeté.

Testing plan

8 tests :

PK relax :

  1. Lever #[ignore] sur rfc0009_add_member_identity_remote_writes_remote_identity_id_column (cf. RFC 0009).
  2. add_member_identity_remote_persists_with_null_user_id.
  3. team_member_lookup_by_id_works_after_migration (sanity).

JWT blacklist by remote : 4. blacklist_by_remote_returns_zero_for_noop_iterator. 5. blacklist_by_remote_inserts_one_row_per_token (DB-mocked). 6. blacklist_by_remote_skips_tokens_for_other_identities.

Signal integration : 7. apply_delete_actor_with_iterator_returns_jwts_blacklisted. 8. apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero (régression).

Migration guide

### Changed (rustwarden-core)

- Migration `m20260510_000016_relax_team_members_pk` (BREAKING schéma).
- `team_member::Model` : nouveau champ `id: Uuid` + bascule de la PK
  SeaORM. Les call-sites `Entity::find_by_id((team_id, user_id))`
  doivent migrer vers `Entity::find().filter(...)`.

### Added (rustwarden-core, feature `experimental-fediverse`)

- `JwtBlacklistService::blacklist_by_remote_identity_id(db, remote_id)`.
- Trait `ActiveTokenIterator` + impl `NoopActiveTokenIterator`.
- `FediverseSignalService::apply_delete_actor_with_iterator(db, ap_id, iter)`.

### Migration steps

1. `cargo run --bin migrate`.
2. Apps consommatrices :
   - Si vous appelez `team_member::Entity::find_by_id`, remplacer par
     un filter explicite.
   - Pour activer l'invalidation JWT-on-Delete{Actor}, implémenter
     `ActiveTokenIterator` selon votre stockage (sessions DB, JWT cache,
     etc.) et le câbler via `with_active_token_iterator(...)`.
3. Le test d'intégration `rfc0009_add_member_identity_remote…` n'est
   plus `#[ignore]`.

Open questions

Toutes résolues à l'auto-revue :

  • PK surrogate id vs PK ternaire (team_id, user_id, remote_identity_id) : surrogate id car SeaORM gère mal les PK composites avec colonnes nullable.
  • Trait ActiveTokenIterator vs étendre JwtBlacklistService : trait pluggable car le storage des tokens actifs varie selon les apps.
  • NoopActiveTokenIterator par défaut : oui, sémantique « pas pire que l'existant », évite de forcer toutes les apps consommatrices à implémenter immédiatement.

Tracking issue

À ouvrir au moment de la PR.


Score d'évaluation

CritèreNote (1-5)Commentaire
Généralité4Le pattern PK surrogate s'applique à d'autres tables avec sibling FK.
Stabilité API3Breaking sur team_member::Model (PK surrogate) — gating par PR bundle.
Performance4Indexes partiels couvrent les deux chemins.
Sécurité5Ferme la promesse Delete{Actor} → invalidation JWT.
Complexité d'implémentation3~500 lignes (migration + Model + service + trait + 8 tests).
Maintenance3Trait à maintenir.
Documentation4Rustdoc EN, doctest, CHANGELOG, migration guide complet.
Total26/35≥ 25 → Accepted