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 :
- Relax la PK composite
team_members(team_id, user_id)vers(team_id, user_id, remote_identity_id)avecuser_idnullable + 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. - Ajouter
JwtBlacklistService::blacklist_by_remote_identity_id(...)qui flag tous les tokens actifs d'une identité distante. BrancheFediverseSignalService::apply_delete_actorpour qu'il fasse passerDeleteOutcome::jwts_blacklistedà un compteur réel (au lieu de toujours0).
Motivation
- Sans le PK relax, RFC 0009 promet une API
add_member_identityqui plante en prod sur les rows distantes (fk_team_members_user_idrejetteUuid::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 rowremote_identitiesest marquéedisabled_atmais les JWT déjà émis restent valides jusqu'à expiration.
Non-goals
- Pas de refonte du blocklist storage — on réutilise la table
jwt_blacklistexistante. - Pas de changement de l'API
add_memberlocale — 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 :
- Pour chaque token actif (table
jwt_blacklistne stocke pas les actifs — il faudra parcourir les sessions ou la tablerefresh_tokens). - Décoder
Claims(sans valider la signature, payload uniquement). - Si
claims.remote_id == Some(remote_identity_id)→ ajouter àjwt_blacklist.
Note importante : la table
jwt_blacklistn'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 traitActiveTokenIteratorque les apps consommatrices implémentent (par défaut, une implNoopActiveTokenIteratorretourne 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 resteNone). - Le test d'intégration
rfc0009_add_member_identity_remote_writes_remote_identity_id_columnperd son#[ignore].
Analyse de compatibilité
-
Breaking sur
team_member::Model(PK change). Les apps qui fontEntity::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).
-
JwtBlacklistServicereste 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 :
- Lever
#[ignore]surrfc0009_add_member_identity_remote_writes_remote_identity_id_column(cf. RFC 0009). add_member_identity_remote_persists_with_null_user_id.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
idvs PK ternaire(team_id, user_id, remote_identity_id): surrogateidcar SeaORM gère mal les PK composites avec colonnes nullable. -
Trait
ActiveTokenIteratorvs étendreJwtBlacklistService: trait pluggable car le storage des tokens actifs varie selon les apps. -
NoopActiveTokenIteratorpar 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ère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Le pattern PK surrogate s'applique à d'autres tables avec sibling FK. |
| Stabilité API | 3 | Breaking sur team_member::Model (PK surrogate) — gating par PR bundle. |
| Performance | 4 | Indexes partiels couvrent les deux chemins. |
| Sécurité | 5 | Ferme la promesse Delete{Actor} → invalidation JWT. |
| Complexité d'implémentation | 3 | ~500 lignes (migration + Model + service + trait + 8 tests). |
| Maintenance | 3 | Trait à maintenir. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide complet. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust