0003-fediverse-remote-team-memberships.md 18345 octets

RFC 0003 — RemoteTeamMembershipService dans rustwarden-fediverse

  • Auteur : EBO (gitrust)
  • Date : 2026-05-15
  • Statut : Draft
  • Apps consommatrices concernées : gitrust (immédiat). Tout futur consommateur qui active experimental-fediverse et veut ajouter une identité remote comme membre d'une team locale.
  • Crate cible : rustwarden-fediverse (PAS rustwarden-core)
  • Dépendance : indépendante des RFCs 0001 et 0002. Pourrait être livrée en premier.

Summary

Ajouter à rustwarden-fediverse un service RemoteTeamMembershipService + table fediverse_team_memberships qui permet d'ajouter une remote_identities.id comme membre d'une teams.id locale avec un rôle. Le service vit dans le crate fédération, sans toucher au TeamService ni au schéma team_members de rustwarden-core. Symétrie complète avec les RFCs 0001 et 0002.

Motivation

Contexte fonctionnel

Les RFCs 0009 et 0014 du framework livrent l'infrastructure d'invitations cross-instance (fediverse_pending_invites + flow accept/reject). Mais une fois l'invite acceptée, rien ne matérialise effectivement la membership :

  • Le modèle SeaORM team_member::Model contient bien un champ remote_identity_id: Option<Uuid> (crates/rustwarden-core/src/models/team_member.rs:28-31) prévu par RFC 0008.
  • Mais la migration framework m20260423_000008_create_teams.rs crée team_members sans cette colonne.
  • Et TeamService::add_member(db, team_id, user_id, role) prend strictement un user_id, jamais un remote_identity_id.

Le champ orphelin trompe la lecture du code : un appel team_members.remote_identity_id retourne toujours None, et un INSERT tenterait d'écrire dans une colonne inexistante (panique SeaORM à l'exécution).

Scénario type côté gitrust

1. Bob (admin de team Dev sur gitrust-B) → invite-fediverse @alice@gitrust-a.test
2. alice reçoit l'invite, accepte via /api/v1/fediverse/invites/{id}/accept (RFC 0014)
3. Le flow framework valide l'invite, marque le row pending_invite comme accepted
4. Échec actuel : impossible de matérialiser alice dans `team_members`.
   - Variante (a) : on créerait un row team_members avec user_id=NULL (rejeté par NOT NULL).
   - Variante (b) : on créerait une shadow user (anti-pattern, cf. discussion gitrust).
   - Variante (c) : la colonne `remote_identity_id` n'existe pas en SQL → panique.

Conséquence : la RFC 0014 (flow d'invitation) livre le rail sans le wagon. Tout consommateur qui veut utiliser les invites cross-instance se retrouve obligé d'écrire un workaround pour stocker effectivement le résultat.

Pourquoi un nouveau service dans fediverse plutôt qu'étendre TeamService ?

Cohérence avec RFC 0001 et 0002 (mêmes arguments) :

  1. Séparation core/fédération. Le crate fediverse maintient son propre schéma cross-instance, le core reste agnostique.
  2. Champ orphelin à supprimer : la présence de remote_identity_id dans team_member::Model côté core est un héritage à nettoyer. Cette RFC propose de l'enlever dans le même mouvement (cf. Migration guide).
  3. Stabilité API : TeamService::add_member(db, team_id, user_id, role) reste inchangé. Aucun consommateur "non-fediverse" n'est cassé.
  4. Cycle de vie : itération libre tant que experimental-fediverse.

Pour gitrust

C'est probablement la RFC la plus urgente des trois (0001, 0002, 0003) car :

  • L'infrastructure framework pour les invites est déjà livrée (RFC 0014).
  • Tant que la matérialisation n'existe pas, le crate fediverse expose une API utilisateur (/api/v1/fediverse/invites/{id}/accept) qui retourne un succès apparent mais ne fait rien d'utile (rien n'est créé dans team_members).
  • C'est une dette de cohérence visible immédiatement par le premier consommateur.

Non-goals

  • Ne PAS modifier rustwarden-core::TeamService ni le schéma team_members.
  • Ne PAS modifier fediverse_pending_invites ni le flow d'acceptation existant — cette RFC ajoute juste l'étape "stocker la membership" qui manque après l'accept.
  • Ne PAS gérer la propagation outbound (un membership locale émet un Add{Actor} AP vers l'instance home du remote ?). Hors scope, à empiler dans une RFC dédiée si pertinent.
  • Ne PAS unifier l'API : TeamService::list_members retourne des team_member::Model (locaux), RemoteTeamMembershipService::list_remote_members retourne des rows séparés. Le consommateur compose les deux pour son rendu (liste mixte).
  • Ne PAS supprimer le champ orphelin team_members.remote_identity_id dans cette même RFC — c'est une Open Question : soit on l'enlève proprement dans une autre RFC pour limiter le scope, soit on l'enlève ici en bonus.

API proposée

Migration SQL (nouvelle migration m20260516_000004_remote_team_memberships.rs)

CREATE TABLE fediverse_team_memberships (
    id UUID PRIMARY KEY,
    team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
    remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
    role VARCHAR(32) NOT NULL,                      -- "member" | "admin" (réutilise la même typologie que team_members.role)
    invited_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
    joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    source_invite_id UUID NULL REFERENCES fediverse_pending_invites(id) ON DELETE SET NULL,
    UNIQUE (team_id, remote_identity_id)
);

CREATE INDEX idx_fediverse_team_memberships_team
    ON fediverse_team_memberships (team_id);
CREATE INDEX idx_fediverse_team_memberships_remote
    ON fediverse_team_memberships (remote_identity_id);

Notes :

  • source_invite_id est une traçabilité bonus : on garde le lien avec l'invite qui a créé la membership, pour audit ou pour révoquer la membership en cas de retrait d'invite a posteriori. Nullable car une membership peut aussi être créée directement par admin sans passer par une invite.
  • role réutilise la sémantique existante de team_members.role ("member" | "admin"). Pas de nouvelle hiérarchie à apprendre.
  • joined_at au lieu de created_at pour cohérence avec team_members.joined_at.

Modèle SeaORM (crates/rustwarden-fediverse/src/models/fediverse_team_membership.rs)

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "fediverse_team_memberships")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    pub team_id: Uuid,
    pub remote_identity_id: Uuid,
    pub role: String,
    pub invited_by_user_id: Option<Uuid>,
    pub joined_at: DateTime<Utc>,
    pub source_invite_id: Option<Uuid>,
}

Service (crates/rustwarden-fediverse/src/services/remote_team_membership_service.rs)

use rustwarden_core::error::AppError;
use sea_orm::DatabaseConnection;
use uuid::Uuid;

/// Cross-instance team memberships: link a `teams.id` local with a
/// `remote_identities.id`. Companion of [`rustwarden_core::services::team_service::TeamService`]
/// but scoped to remote identities.
pub struct RemoteTeamMembershipService;

impl RemoteTeamMembershipService {
    /// Add a remote identity as a member of a local team.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if `team_id` or `remote_identity_id` does not exist.
    /// - [`AppError::Validation`] if `role` is unknown.
    /// - [`AppError::Database`] on persistence error (incl. UNIQUE constraint violation,
    ///   which is mapped to `AlreadyExists`).
    pub async fn add_member(
        db: &DatabaseConnection,
        team_id: Uuid,
        remote_identity_id: Uuid,
        role: &str,
        invited_by_user_id: Option<Uuid>,
        source_invite_id: Option<Uuid>,
    ) -> Result<fediverse_team_membership::Model, AppError> { /* ... */ }

    /// Remove a remote membership.
    pub async fn remove_member(
        db: &DatabaseConnection,
        team_id: Uuid,
        remote_identity_id: Uuid,
    ) -> Result<bool, AppError> { /* ... */ }

    /// Change the role of an existing remote member.
    pub async fn update_role(
        db: &DatabaseConnection,
        team_id: Uuid,
        remote_identity_id: Uuid,
        new_role: &str,
    ) -> Result<bool, AppError> { /* ... */ }

    /// List remote members of a team (for admin UI display).
    pub async fn list_remote_members(
        db: &DatabaseConnection,
        team_id: Uuid,
    ) -> Result<Vec<fediverse_team_membership::Model>, AppError> { /* ... */ }

    /// List all teams a remote identity belongs to.
    pub async fn list_teams_for_remote_identity(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
    ) -> Result<Vec<fediverse_team_membership::Model>, AppError> { /* ... */ }

    /// Check whether a remote identity is a member of a given team
    /// (with optional role check). Used by permission resolution.
    pub async fn is_member(
        db: &DatabaseConnection,
        team_id: Uuid,
        remote_identity_id: Uuid,
    ) -> Result<Option<String>, AppError> { /* ... */ }
}

Branchement dans le flow d'acceptation d'invite (RFC 0014)

L'acceptation d'une invite cross-instance (crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs) doit, après validation, appeler :

RemoteTeamMembershipService::add_member(
    db,
    pending_invite.team_id,
    pending_invite.remote_identity_id,
    pending_invite.role.as_str(),
    Some(pending_invite.inviter_id),
    Some(pending_invite.id),
).await?;

C'est cette ligne qui ferme la boucle entre l'infrastructure d'invite (déjà livrée) et la matérialisation de la membership (livrée par cette RFC).

Exemple d'usage côté consommateur (gitrust)

// Permission resolution sur un repository, dans gitrust-core
pub async fn effective_role_via_teams_for_identity(
    db: &DatabaseConnection,
    repo_id: Uuid,
    identity: Identity,
) -> Result<Option<Role>, AppError> {
    match identity {
        Identity::Local(user_id) => {
            // Logique existante : list teams of user, then check resource_share.shared_with_team_id
            // ...
        }
        Identity::Remote(remote_id) => {
            // Nouveau : list teams of remote identity
            let teams = RemoteTeamMembershipService::list_teams_for_remote_identity(
                db, remote_id,
            ).await?;
            // Puis pour chaque team, check si la team a un resource_share sur ce repo
            // (utilise ResourceService::list_shares existant côté core)
            // ...
        }
    }
}

Composition propre : RemoteTeamMembershipService côté fediverse pour "qui est dans quelles teams" + ResourceService côté core pour "quelles teams ont accès à quoi", combinés côté consommateur.

Analyse de compatibilité

  • API existante non modifiée → non-breaking.
  • Nouvelle API ajoutée : RemoteTeamMembershipService (6 méthodes) + nouveau modèle SeaORM.
  • Schéma DB : nouvelle table dans FediverseMigrator. Aucun ALTER sur table existante.
  • TeamService::add_member du framework : signature inchangée.
  • Champ orphelin team_members.remote_identity_id : peut être supprimé proprement (cf. Open Questions). N'a jamais été matérialisé en SQL, donc pas de migration de données.
  • Flow accept invite : doit appeler le nouveau service, c'est une modif interne au crate fediverse, pas un breaking pour les consommateurs.

Verdict : 100 % additif côté API publique. La seule modif interne (flow accept invite branche add_member) est transparente pour le consommateur, qui voit juste "ça marche enfin".

Alternatives considérées

Alternative A (recommandée) : table + service séparés dans fediverse

Voir API proposée. Cohérence avec RFCs 0001 et 0002.

Alternative B : matérialiser team_members.remote_identity_id côté core

Pros :

  • Une seule table à interroger pour lister les membres d'une team.
  • Le champ orphelin disparaît naturellement (il devient fonctionnel).

Cons :

  • Migration ALTER TABLE team_members côté core + CHECK constraint XOR.
  • TeamService doit apprendre le concept "remote identity".
  • Couple le cycle release des deux concepts.
  • Casse la séparation core/fédération posée par le framework.

Alternative C : passer par ResourceService::share_with(Principal::Team) uniquement, jamais membres directs

Pros : zéro nouvelle table.

Cons :

  • Modèle d'invitation perd son sens (RFC 0014 livre une infrastructure d'invite, on ne pourrait que rejeter ou … rien).
  • Force le consommateur à inventer un autre mécanisme pour exprimer "alice est dans la team Dev".
  • Régression fonctionnelle par rapport à l'intention des RFCs 0009/0014.

Choix : Alternative A. Cohérent avec 0001 et 0002, débloque immédiatement la valeur de la RFC 0014.

Testing plan

Unit

Dans crates/rustwarden-fediverse/src/services/remote_team_membership_service.rs#tests :

  • Happy paths (6 méthodes × 1 cas = 6 tests).
  • Cas d'erreur :
    • add_member : team_id inexistant → NotFound.
    • add_member : remote_identity_id inexistant → NotFound.
    • add_member : role non reconnu → Validation.
    • add_member × 2 sur même (team_id, remote_identity_id) → AlreadyExists (via UNIQUE).
    • remove_member sur pair inexistant → retourne false, pas d'erreur.
    • update_role sur pair inexistant → retourne false.
    • update_role avec rôle invalide → Validation.
    • is_member sans row → None.
    • is_member avec row → Some(role).

Doctest

Exemple add_member + list_remote_members dans le doc comment de struct (no_run).

Integration

Test du flow complet : invite-fediverse → user remote accept → vérifier RemoteTeamMembershipService::is_member retourne Some(role).

Compat

cargo test -p rustwarden-core vert.

Vérifier que team_members::ActiveModel::insert continue de fonctionner sans remote_identity_id.

Bench

Non requis.

Fuzz

Non requis.

Migration guide

À copier dans le CHANGELOG.md de rustwarden-fediverse au merge :

Added (0.X+1.0)

  • RemoteTeamMembershipService — gérer les memberships cross-instance dans les teams locales.
  • Nouveau modèle models::fediverse_team_membership::Model.
  • Migration m20260516_000004_remote_team_memberships ajoutée à FediverseMigrator.
  • Le flow d'acceptation d'invite cross-instance (RFC 0014) appelle désormais add_member automatiquement, fermant la boucle d'invitation. C'est la livraison effective de la RFC 0014.

Changed

  • FediverseInviteService::accept_invite matérialise désormais une membership dans fediverse_team_memberships. Comportement avant la RFC : l'invite passait en statut accepted mais aucune membership n'était créée (silent no-op). Après : la membership est créée et lisible via list_remote_members.

Impact consommateurs : positif uniquement. Tout consommateur qui avait abandonné l'usage de accept-fediverse faute de matérialisation peut le rebrancher directement.

Deprecated (proposition, à valider)

Steps pour gitrust spécifiquement

  1. Bump rustwarden-fediverse dans Cargo.toml.
  2. Re-tester le scénario E2E #2 du plan d'intégration (invite cross-instance accept → vérifier membership effective).
  3. Mettre à jour RepositoryService::effective_role_for_identity côté gitrust pour interroger RemoteTeamMembershipService::list_teams_for_remote_identity quand l'identité est Remote (pour résoudre les permissions via teams).

Open questions

  • Suppression du champ orphelin team_members.remote_identity_id : enlever dans cette RFC (bonus, additif au workflow) ou dans une RFC dédiée ? Recommandation : laisser dans cette RFC sous forme #[deprecated] only, retirer physiquement dans une major future.
  • Rôles supportés : aligner exactement sur team_members.role ("member" | "admin") ou élargir ? Le framework ne semble pas avoir de hiérarchie plus riche, donc juste 2 niveaux suffisent.
  • Outbound Add{Actor} AP : émettre une activité notifiant l'instance home du remote qu'il a été ajouté à une team locale ? Pratique pour audit / dashboard remote. Hors scope V1, à empiler.
  • Listing combiné : faut-il une méthode helper list_all_members(team_id) -> Vec<TeamMember | FediverseTeamMembership> ? Probablement plus propre côté consommateur via deux appels successifs et un merge.
  • Cascade Delete{Actor} : ON DELETE CASCADE sur remote_identity_id purge la row. OK pour V1.

Score sur la grille du processus §4

CritèreNoteJustification
Généralité5Débloque la RFC 0014 pour tout consommateur AP
Stabilité API5Additif pur (sauf le #[deprecated] proposé, qui est non-breaking)
Performance5Lookup indexé, aucune regression possible
Sécurité4Pas de nouveau vecteur (rôles validés, cascade Delete{Actor})
Complexité impl4< 200 LOC service + petit branchement dans accept_invite
Maintenance5Zéro nouvelle dépendance
Documentation5Rustdoc + doctest + entrée CHANGELOG + guide migration
Total33/35≥ 30 = accélération possible

Tracking issue

À ouvrir sur le repo rustwarden-core avec labels rfc + area:fediverse + priority:high (débloque RFC 0014 livrée).


Historique :

  • 2026-05-15 : Draft initial — gap identifié pendant la planification de l'intégration fediverse côté gitrust ; champ orphelin team_members.remote_identity_id jamais matérialisé en SQL malgré sa présence dans le modèle SeaORM.