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-fediverseet veut ajouter une identité remote comme membre d'une team locale. - Crate cible :
rustwarden-fediverse(PASrustwarden-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::Modelcontient bien un champremote_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.rscréeteam_memberssans cette colonne. - Et
TeamService::add_member(db, team_id, user_id, role)prend strictement unuser_id, jamais unremote_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) :
- Séparation core/fédération. Le crate fediverse maintient son propre schéma cross-instance, le core reste agnostique.
- Champ orphelin à supprimer : la présence de
remote_identity_iddansteam_member::Modelcôté core est un héritage à nettoyer. Cette RFC propose de l'enlever dans le même mouvement (cf. Migration guide). - Stabilité API :
TeamService::add_member(db, team_id, user_id, role)reste inchangé. Aucun consommateur "non-fediverse" n'est cassé. - 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éé dansteam_members). - C'est une dette de cohérence visible immédiatement par le premier consommateur.
Non-goals
- Ne PAS modifier
rustwarden-core::TeamServiceni le schémateam_members. - Ne PAS modifier
fediverse_pending_invitesni 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_membersretourne desteam_member::Model(locaux),RemoteTeamMembershipService::list_remote_membersretourne 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_iddans 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_idest 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.roleréutilise la sémantique existante deteam_members.role("member" | "admin"). Pas de nouvelle hiérarchie à apprendre.joined_atau lieu decreated_atpour cohérence avecteam_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_memberdu 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_memberscôté core + CHECK constraint XOR. TeamServicedoit 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_idinexistant →NotFound.add_member:remote_identity_idinexistant →NotFound.add_member:rolenon reconnu →Validation.add_member× 2 sur même(team_id, remote_identity_id)→AlreadyExists(via UNIQUE).remove_membersur pair inexistant → retournefalse, pas d'erreur.update_rolesur pair inexistant → retournefalse.update_roleavec rôle invalide →Validation.is_membersans row →None.is_memberavec 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_membershipsajoutée àFediverseMigrator. - Le flow d'acceptation d'invite cross-instance (RFC 0014) appelle désormais
add_memberautomatiquement, fermant la boucle d'invitation. C'est la livraison effective de la RFC 0014.
Changed
FediverseInviteService::accept_invitematérialise désormais une membership dansfediverse_team_memberships. Comportement avant la RFC : l'invite passait en statutacceptedmais aucune membership n'était créée (silent no-op). Après : la membership est créée et lisible vialist_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)
- Le champ
team_member::Model::remote_identity_id(crates/rustwarden-core/src/models/team_member.rs:28-31) est marqué#[deprecated]puisque jamais matérialisé et supplanté parfediverse_team_memberships. À supprimer en major suivante.
Steps pour gitrust spécifiquement
- Bump
rustwarden-fediversedansCargo.toml. - Re-tester le scénario E2E #2 du plan d'intégration (invite cross-instance accept → vérifier membership effective).
- Mettre à jour
RepositoryService::effective_role_for_identitycôté gitrust pour interrogerRemoteTeamMembershipService::list_teams_for_remote_identityquand l'identité estRemote(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 CASCADEsurremote_identity_idpurge la row. OK pour V1.
Score sur la grille du processus §4
| Critère | Note | Justification |
|---|---|---|
| Généralité | 5 | Débloque la RFC 0014 pour tout consommateur AP |
| Stabilité API | 5 | Additif pur (sauf le #[deprecated] proposé, qui est non-breaking) |
| Performance | 5 | Lookup indexé, aucune regression possible |
| Sécurité | 4 | Pas de nouveau vecteur (rôles validés, cascade Delete{Actor}) |
| Complexité impl | 4 | < 200 LOC service + petit branchement dans accept_invite |
| Maintenance | 5 | Zéro nouvelle dépendance |
| Documentation | 5 | Rustdoc + doctest + entrée CHANGELOG + guide migration |
| Total | 33/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_idjamais matérialisé en SQL malgré sa présence dans le modèle SeaORM.
GitRust