0003-remote-identity-store.md 16226 octets

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-core avec la feature experimental-fediverse qui 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 (types WebFingerResponse, 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 : ajouter pub mod remote_identity; (gated experimental-fediverse).
  • crates/rustwarden-core/src/services/mod.rs : ajouter pub mod remote_identity_service; (gated).
  • crates/rustwarden-core/src/fediverse/mod.rs : ajouter pub mod fetcher; + reexport pub use fetcher::{RemoteFetcher, ReqwestRemoteFetcher};.
  • crates/rustwarden-core/migrations/src/lib.rs : enregistrer la nouvelle migration dans le Migrator.
  • 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 pub derriè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_id impose 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 :

  1. is_stale_returns_false_within_ttl — pure helper, pas de DB.
  2. is_stale_returns_true_past_ttl — pure helper.
  3. upsert_from_person_inserts_new_when_uri_unknown — MockDatabase, vérifie INSERT généré.
  4. upsert_from_person_updates_existing_record — MockDatabase avec ligne existante.
  5. upsert_from_person_rejects_domain_mismatch — expected_domain != json.id.host() → FediverseError::DomainMismatch.
  6. find_by_uri_returns_none_when_absent — MockDatabase vide.
  7. find_by_handle_returns_none_when_absent — idem.
  8. mark_disabled_sets_disabled_at — MockDatabase, vérifie UPDATE.
  9. resolve_handle_returns_cached_when_fresh — stub fetcher qui panic si appelé ; l'appel doit retourner depuis le cache.
  10. resolve_handle_calls_fetcher_when_stale — stub fetcher renvoie un Person, vérifie qu'il est upserted.
  11. resolve_handle_propagates_webfinger_404 — stub fetcher renvoie UserNotFound, l'erreur remonte.
  12. 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.local depuis Rustwarden → vérifier que la ligne remote_identities est 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 imposer NOT NULL ou laisser NULL pour les acteurs résolus avant que l'on conserve le JSON brut (futur retraitement) ? Recommandation : nullable au début, NOT NULL plus tard.
  • TTL : valeur par défaut 86 400 s (24 h, alignée avec Mastodon). À confirmer.
  • disabled_at vs DELETE : on garde disabled_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é aliases pointant vers https://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èreNoteCommentaire
Généralité4Sert toute app fédérée ; reste spécifique au cas d'usage AP.
Stabilité API4Additif via feature, mais migration DB introduit du schéma public engagé pour la durée de vie de la feature.
Performance4Cache 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é4Domain 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émentation3~600 lignes (entité + migration + service + trait + impl reqwest + ~12 tests). Touche plusieurs modules.
Charge de maintenance3Schéma DB stable. Le format JSON ActivityPub évolue dans Mastodon ; on reste tolérant en parsage (raw_json conserve l'original).
Documentation4Rustdoc EN sur tous les pub, doctests, CHANGELOG, migration guide.
Total26/35≥ 25 → Accepted