RFC 0003 — Fediverse: Stockage des identités distantes + résolution @user@domain
- Auteur : EBO (rustwarden)
- Date : 2026-05-09
- Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 26/35)
- Apps consommatrices concernées : toute app utilisant
rustwarden-coreavec la featureexperimental-fediversequi doit interagir avec des utilisateurs Fediverse externes (consulter un profil distant, l'inviter dans une équipe, mentionner par handle). - Plan associé :
docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 1. - Dépendances : RFC 0001 (
FediverseConfig), RFC 0002 (typesWebFingerResponse,PersonJson).
Summary
Introduire la table remote_identities (cache local des acteurs Fediverse distants), son entité SeaORM, et un service RemoteIdentityService qui orchestre la résolution d'un handle @user@domain via WebFinger + fetch d'acteur, avec cache TTL configurable. La couche HTTP est abstraite derrière le trait RemoteFetcher pour permettre des tests unitaires propres.
Motivation
- Sans cache local, chaque interaction avec un utilisateur distant nécessiterait de re-faire WebFinger + fetch d'acteur (~ 2 round-trips HTTPS), inacceptable au-delà de quelques utilisateurs.
- Sans table dédiée, les RFCs 0008 (
Identity) et 0009 (invitations cross-instance) n'auraient nulle part où pointer leurs FK pour les identités distantes. - Sans abstraction HTTP, les tests des services dépendraient du réseau et des instances Mastodon réelles.
Conséquence si on ne fait rien : les invitations cross-instance (Phase 3) et les signaux de lifecycle (Phase 4) sont bloqués, faute d'identifiant interne stable pour les utilisateurs distants.
Non-goals
- Pas de migration de schéma sur
users— RFC 0005 fera ça pour les clés AP des utilisateurs locaux. - Pas de FK sibling sur
team_members/resource_shares— RFC 0008. - Pas de modèle de confiance (allowlist/blocklist appliquée avant fetch) — RFC 0004 ; cette RFC suppose que la résolution est autorisée et laisse la vérification trust aux RFCs suivantes (les services exposent juste des hooks pour brancher la vérification).
- Pas d'authorized fetch (signatures sur GET sortants) — RFC 0010.
- Pas de webhook ou d'invalidation push du cache — TTL pull-based uniquement.
API proposée
Migration m20260510_000009_remote_identities
crates/rustwarden-core/migrations/src/m20260510_000009_remote_identities.rs :
use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.create_table( Table::create().table(RemoteIdentities::Table) .col(ColumnDef::new(RemoteIdentities::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(RemoteIdentities::ApId).text().not_null().unique_key()) .col(ColumnDef::new(RemoteIdentities::Username).text().not_null()) .col(ColumnDef::new(RemoteIdentities::Domain).text().not_null()) .col(ColumnDef::new(RemoteIdentities::DisplayName).text().null()) .col(ColumnDef::new(RemoteIdentities::SummaryHtml).text().null()) .col(ColumnDef::new(RemoteIdentities::InboxUrl).text().not_null()) .col(ColumnDef::new(RemoteIdentities::SharedInboxUrl).text().null()) .col(ColumnDef::new(RemoteIdentities::PublicKeyPem).text().not_null()) .col(ColumnDef::new(RemoteIdentities::IconUrl).text().null()) .col(ColumnDef::new(RemoteIdentities::ActorType).text().not_null().default("Person")) .col(ColumnDef::new(RemoteIdentities::RawJson).json_binary().null()) .col(ColumnDef::new(RemoteIdentities::LastFetchedAt).timestamp_with_time_zone().not_null()) .col(ColumnDef::new(RemoteIdentities::DisabledAt).timestamp_with_time_zone().null()) .col(ColumnDef::new(RemoteIdentities::CreatedAt).timestamp_with_time_zone().not_null()) .col(ColumnDef::new(RemoteIdentities::UpdatedAt).timestamp_with_time_zone().not_null()) .to_owned() ).await?; manager.create_index( Index::create() .table(RemoteIdentities::Table) .name("uniq_remote_identities_handle") .col(RemoteIdentities::Username).col(RemoteIdentities::Domain) .unique().to_owned() ).await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_table(Table::drop().table(RemoteIdentities::Table).to_owned()).await } }
Entité SeaORM
crates/rustwarden-core/src/models/remote_identity.rs :
use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] #[sea_orm(table_name = "remote_identities")] pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, #[sea_orm(unique)] pub ap_id: String, pub username: String, pub domain: String, pub display_name: Option<String>, pub summary_html: Option<String>, pub inbox_url: String, pub shared_inbox_url: Option<String>, pub public_key_pem: String, pub icon_url: Option<String>, pub actor_type: String, pub raw_json: Option<serde_json::Value>, pub last_fetched_at: DateTime<Utc>, pub disabled_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {}
Trait RemoteFetcher + impl par défaut
crates/rustwarden-core/src/fediverse/fetcher.rs (nouveau) :
use async_trait::async_trait; use url::Url; use super::error::FediverseError; use super::types::{PersonJson, WebFingerResponse}; /// Abstracts HTTP I/O so the service layer is unit-testable. #[async_trait] pub trait RemoteFetcher: Send + Sync { async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError>; async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError>; } /// Default implementation backed by `reqwest 0.13` (transitively pulled by /// `activitypub_federation`). Honours timeouts from `FediverseConfig`. pub struct ReqwestRemoteFetcher { client: reqwest::Client, user_agent: String, } impl ReqwestRemoteFetcher { pub fn new(config: &crate::config::FediverseConfig) -> Result<Self, FediverseError> { /* … */ } } #[async_trait] impl RemoteFetcher for ReqwestRemoteFetcher { async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError> { /* … */ } async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError> { /* … */ } }
Service
crates/rustwarden-core/src/services/remote_identity_service.rs (nouveau) :
use std::sync::Arc; use chrono::{DateTime, Utc}; use sea_orm::DatabaseConnection; use uuid::Uuid; use crate::error::AppError; use crate::fediverse::error::FediverseError; use crate::fediverse::fetcher::RemoteFetcher; use crate::fediverse::types::PersonJson; use crate::models::remote_identity; pub struct RemoteIdentityService { db: DatabaseConnection, fetcher: Arc<dyn RemoteFetcher>, cache_ttl_secs: u64, } impl RemoteIdentityService { pub fn new( db: DatabaseConnection, fetcher: Arc<dyn RemoteFetcher>, config: &crate::config::FediverseConfig, ) -> Self { Self { db, fetcher, cache_ttl_secs: config.fetch_actor_cache_ttl().as_secs(), } } /// Resolve a `@user@domain` handle, returning the stored row. /// Hits the cache first; on miss or stale, fetches via WebFinger + actor URL. pub async fn resolve_handle(&self, handle: &str) -> Result<remote_identity::Model, FediverseError> { /* … */ } /// Direct DB lookup by handle (no fetch). pub async fn find_by_handle(&self, username: &str, domain: &str) -> Result<Option<remote_identity::Model>, AppError> { /* … */ } /// Direct DB lookup by AP id (no fetch). pub async fn find_by_uri(&self, ap_id: &str) -> Result<Option<remote_identity::Model>, AppError> { /* … */ } /// Insert or update a remote identity from a freshly fetched `PersonJson`. pub async fn upsert_from_person(&self, person: PersonJson, expected_domain: &str) -> Result<remote_identity::Model, FediverseError> { /* … */ } /// Mark a remote identity as disabled (used by `Delete{Actor}` in RFC 0010). pub async fn mark_disabled(&self, id: Uuid) -> Result<(), AppError> { /* … */ } /// Helper: is this row past the configured cache TTL? pub fn is_stale(&self, last_fetched_at: DateTime<Utc>) -> bool { /* … */ } }
Câblage
crates/rustwarden-core/src/models/mod.rs: ajouterpub mod remote_identity;(gatedexperimental-fediverse).crates/rustwarden-core/src/services/mod.rs: ajouterpub mod remote_identity_service;(gated).crates/rustwarden-core/src/fediverse/mod.rs: ajouterpub mod fetcher;+ reexportpub use fetcher::{RemoteFetcher, ReqwestRemoteFetcher};.crates/rustwarden-core/migrations/src/lib.rs: enregistrer la nouvelle migration dans leMigrator.- Pas de changement à
with_fediverse()à ce stade — le service est instanciable manuellement par les RFCs suivantes.
Analyse de compatibilité
- API existante non modifiée → non-breaking.
-
Nouveau item
pubderrière feature flag → minor. - Changement de schéma DB → oui, nouvelle table. Migration up/down réversible.
- Changement de format sérialisé → non.
- Nouveau comportement par défaut → la table est créée à la migration mais inutilisée tant que personne n'instancie le service.
Alternatives considérées
Alternative A (recommandée) — Service + trait RemoteFetcher
Service en tant que struct concrète (pas trait), HTTP abstrait derrière un trait pour les tests.
Pros : balance pragmatique entre testabilité (mock fetcher) et simplicité (service direct, pas un trait de plus).
Cons : aucun majeur.
Alternative B — activitypub_federation::ObjectId<RemoteIdentity> natif
Utiliser le système d'ObjectId de la crate AP qui gère cache et fetch automatiquement.
Pros : moins de code custom.
Cons :
- Couple le modèle métier au design de la crate AP (changements API en 0.7-beta).
- Le cache de la crate AP est in-memory (
moka), pas DB-persistent. On veut la persistance. - Le contrat
Object::read_from_idimpose une signature qu'on ne peut pas adapter à notre table.
Rejeté — on garde notre propre cache DB-backed, on appellera ponctuellement les helpers de la crate AP pour les signatures HTTP plus tard (RFC 0010).
Alternative C — Service générique sans abstraction HTTP
Pas de trait RemoteFetcher, appel direct à reqwest.
Pros : un trait de moins.
Cons : tests unitaires impossibles sans réseau / serveur factice. Casse le pattern « tests inline avec stubs » utilisé partout dans Rustwarden.
Rejeté.
Alternative D — Ne rien faire
Bloque RFC 0008 et 0009. Rejeté.
Testing plan
Tests unitaires (à écrire AVANT — RED phase)
Service tests utilisent sea_orm::MockDatabase (pattern obs S18) ou un DatabaseConnection Postgres réel selon le test :
is_stale_returns_false_within_ttl— pure helper, pas de DB.is_stale_returns_true_past_ttl— pure helper.upsert_from_person_inserts_new_when_uri_unknown— MockDatabase, vérifie INSERT généré.upsert_from_person_updates_existing_record— MockDatabase avec ligne existante.upsert_from_person_rejects_domain_mismatch—expected_domain != json.id.host()→FediverseError::DomainMismatch.find_by_uri_returns_none_when_absent— MockDatabase vide.find_by_handle_returns_none_when_absent— idem.mark_disabled_sets_disabled_at— MockDatabase, vérifie UPDATE.resolve_handle_returns_cached_when_fresh— stub fetcher qui panic si appelé ; l'appel doit retourner depuis le cache.resolve_handle_calls_fetcher_when_stale— stub fetcher renvoie un Person, vérifie qu'il est upserted.resolve_handle_propagates_webfinger_404— stub fetcher renvoieUserNotFound, l'erreur remonte.resolve_handle_returns_error_for_invalid_handle_format—"not-a-handle"→InvalidResource.
Doctests : 1 sur RemoteIdentityService::resolve_handle (rust,ignore — nécessite DB), 1 sur RemoteFetcher (trait doc).
Quality gates
Mêmes que RFC 0001/0002.
Coverage cible : ≥ 90 % sur remote_identity_service (composant central, beaucoup de chemins d'erreur).
Tests d'intégration (post-implémentation, #[ignore])
- Lancer une instance Mastodon Docker, résoudre
@admin@masto-test.localdepuis Rustwarden → vérifier que la ligneremote_identitiesest insérée avec les bons champs. - Re-résoudre dans la même fenêtre TTL → vérifier qu'aucun appel HTTP n'est fait (compteur sur le fetcher mock).
Migration guide
### Added (post RFC 0003) - New SeaORM migration `m20260510_000009_remote_identities`. Creates the `remote_identities` table (cache for remote ActivityPub actors). - Public types: `rustwarden_core::models::remote_identity::Model`, `rustwarden_core::services::remote_identity_service::RemoteIdentityService`, `rustwarden_core::fediverse::{RemoteFetcher, ReqwestRemoteFetcher}`. ### Migration steps For consumers using `with_fediverse()` and `auto_migrate(true)`: nothing to do — the migration runs automatically. For consumers managing migrations manually: run `cargo run --bin migrate` (or the equivalent SeaORM command) to create the new table. No public API breakage — existing routes and types are unchanged.
Open questions
-
raw_json: faut-il imposerNOT NULLou laisserNULLpour les acteurs résolus avant que l'on conserve le JSON brut (futur retraitement) ? Recommandation : nullable au début,NOT NULLplus tard. - TTL : valeur par défaut 86 400 s (24 h, alignée avec Mastodon). À confirmer.
-
disabled_atvs DELETE : on gardedisabled_at(tombstone soft-delete) plutôt qu'une suppression dure pour préserver l'audit trail. Confirmé. -
WebFinger via shared inbox shortcut : si l'IdP a déjà publié
aliasespointant vershttps://other.tld/users/X, on peut shortcuter le second fetch ? Recommandation : oui, mais validation stricte du domaine après.
Tracking issue
À créer sur le repo upstream avec le label rfc une fois la RFC en Under Review.
Score d'évaluation (auto-revue 2026-05-09)
| Critère | Note | Commentaire |
|---|---|---|
| Généralité | 4 | Sert toute app fédérée ; reste spécifique au cas d'usage AP. |
| Stabilité API | 4 | Additif via feature, mais migration DB introduit du schéma public engagé pour la durée de vie de la feature. |
| Performance | 4 | Cache TTL 24 h évite les round-trips répétés. Trait RemoteFetcher permet d'optimiser plus tard (HTTP/2, connection pooling) sans casser l'API. |
| Sécurité | 4 | Domain validation lors de l'upsert empêche le spoofing trivial. La vérification trust (allowlist/blocklist) est laissée à RFC 0004 — nécessitera un hook ici plus tard. |
| Complexité d'implémentation | 3 | ~600 lignes (entité + migration + service + trait + impl reqwest + ~12 tests). Touche plusieurs modules. |
| Charge de maintenance | 3 | Schéma DB stable. Le format JSON ActivityPub évolue dans Mastodon ; on reste tolérant en parsage (raw_json conserve l'original). |
| Documentation | 4 | Rustdoc EN sur tous les pub, doctests, CHANGELOG, migration guide. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust