RFC 0009 — Fediverse: équipes / partages cross-instance + invitations
- 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-corequi veut autoriser un utilisateur fediverse externe à rejoindre une équipe ou recevoir un partage de ressource. - Plan associé :
docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 3 — clôture. - Dépendances : RFC 0008 (
identity-abstraction), RFC 0011 (fediverse-outbound-worker), RFC 0006 (ap-native-auth-flow).
Summary
Introduire la table fediverse_pending_invites (state machine Pending → Accepted | Rejected | Revoked | Expired) et le service FediverseInviteService qui orchestre les invitations cross-instance d'équipe ; étendre team_service avec les méthodes add_member_identity(team_id, identity, role) / teams_of_identity(identity) qui acceptent un Identity (RFC 0008) et persistent dans la colonne adéquate (user_id ou remote_identity_id). Les routes HTTP et les helpers de construction des activités AP Invite{TeamMembership}, Accept{Invite}, Reject{Invite}, Revoke{Membership} sont livrés mais leur émission via FediverseOutboundService::enqueue reste à la charge du consommateur (les routes elles-mêmes sont reportées à un follow-up RFC routes-only, comme pour RFC 0006).
Motivation
RFC 0008 a livré le contrat type-safe Identity + les colonnes sibling FK ; aujourd'hui le team_service accepte toujours uniquement un Uuid /* users.id */. Sans cette RFC, RFC 0010 (signaux lifecycle) n'a pas de cible : aucune équipe ne contient de membre distant à révoquer, aucun partage n'a de destinataire fédéré.
Sans cette RFC, le master plan §10 Phase 3 « b.tld invite alice@a.tld » est purement théorique.
Non-goals
- Pas de routes HTTP côté admin / IdP — un follow-up RFC les ajoutera (cf. les 5 endpoints listés dans le master plan §10 Phase 3 livrables).
- Pas de UI HTML —
rustwarden-uiajoutera dans la Phase 3 finale les écrans/admin/teams/{id}enrichi +/profile/invites. - Pas de modification du
resource_service::share_with_remote_identity— différé à un follow-up qui peut s'appuyer sur le même pattern. La table support existe (RFC 0008), seul le service reste à étendre. - Pas de transport HTTP-Sig — délégué à
OutboundDelivery(RFC 0011 + RFC 0010). - Pas de modification de la PK composite
team_members(team_id, user_id)— on contourne en utilisant un index unique partiel sur(team_id, remote_identity_id)au lieu de transformer la PK.
API proposée
Migration m20260510_000015_fediverse_pending_invites
CREATE TABLE fediverse_pending_invites ( id UUID PRIMARY KEY, activity_id TEXT NOT NULL UNIQUE, -- AP Invite activity id team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, invited_handle TEXT NOT NULL, -- @user@domain invited_remote_identity_id UUID NULL, -- résolu lors de l'accept role TEXT NOT NULL, -- "member"|"admin" invited_by_user_id UUID NOT NULL REFERENCES users(id), status TEXT NOT NULL DEFAULT 'pending', -- pending|accepted|rejected|revoked|expired created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, decided_at TIMESTAMPTZ NULL ); CREATE INDEX fediverse_pending_invites_status_idx ON fediverse_pending_invites (status, expires_at); -- Index unique partiel pour empêcher deux memberships remote pour -- la même paire (team, remote_identity) sans toucher à la PK. CREATE UNIQUE INDEX team_members_remote_unique ON team_members (team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULL;
Énum
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InviteStatus { Pending, Accepted, Rejected, Revoked, Expired }
Extension team_service
impl TeamService { /// Add a member by [`Identity`] — local or remote. The row goes /// into `team_members` with the appropriate sibling FK populated. pub async fn add_member_identity( db: &DatabaseConnection, team_id: Uuid, identity: Identity, role: TeamRole, hooks: Option<&dyn Hooks>, ) -> Result<team_member::Model, AppError>; /// Teams the [`Identity`] belongs to (membership only, not owner). pub async fn teams_of_identity( db: &DatabaseConnection, identity: Identity, ) -> Result<Vec<team::Model>, AppError>; }
add_member_identity(Local(uuid)) reste rétrocompatible avec add_member(uuid) existant — c'est un wrapper transparent.
Service FediverseInviteService
pub struct FediverseInviteService; impl FediverseInviteService { /// Persist a `pending` invite + return its `id`. Caller enqueues /// the AP `Invite{TeamMembership}` activity via outbound service. pub async fn invite_remote( db: &DatabaseConnection, team_id: Uuid, invited_handle: &str, role: TeamRole, invited_by_user_id: Uuid, ttl: Duration, ) -> Result<fediverse_pending_invites::Model, AppError>; /// IdP-side accept: marks the invite `Accepted`, resolves the /// remote identity, and creates the `team_members` row. pub async fn accept( db: &DatabaseConnection, invite_id: Uuid, remote_identity_id: Uuid, hooks: Option<&dyn Hooks>, ) -> Result<team_member::Model, AppError>; /// IdP-side reject. pub async fn reject( db: &DatabaseConnection, invite_id: Uuid, ) -> Result<fediverse_pending_invites::Model, AppError>; /// Inviter-side revoke (before or after accept). pub async fn revoke( db: &DatabaseConnection, invite_id: Uuid, ) -> Result<fediverse_pending_invites::Model, AppError>; pub async fn list_pending_for_team( db: &DatabaseConnection, team_id: Uuid, ) -> Result<Vec<fediverse_pending_invites::Model>, AppError>; }
Helpers de payload AP
pub fn build_invite_payload( config: &FediverseConfig, invite: &fediverse_pending_invites::Model, team: &team::Model, ) -> serde_json::Value; pub fn build_accept_invite_payload( config: &FediverseConfig, invite_activity_id: &str, ) -> serde_json::Value; pub fn build_reject_invite_payload( config: &FediverseConfig, invite_activity_id: &str, ) -> serde_json::Value; pub fn build_revoke_membership_payload( config: &FediverseConfig, membership_id: Uuid, team_id: Uuid, ) -> serde_json::Value;
Analyse de compatibilité
-
Non-breaking : nouvelle table + nouvelles méthodes. L'API
team_service::add_memberexistante reste inchangée et continue d'accepterUuidbrut. - Migration up/down réversible.
- Aucune API publique existante n'est modifiée ou supprimée.
Alternatives considérées
Alternative A (recommandée) — Table dédiée fediverse_pending_invites + service séparé
Pros : isolation propre, n'alourdit pas team_service, facile à indexer, audit trail clair.
Cons : duplication potentielle avec un futur fediverse_*_invites plus générique.
Alternative B — Stocker l'invitation dans team_members avec status='pending'
Cons : pollue les requêtes existantes (SELECT * FROM team_members WHERE team_id=$1 reverrait des invitations non acceptées) ; nécessite de filtrer partout.
Rejeté.
Alternative C — Stocker dans notifications génériques
Cons : pas d'index sur le statut ; inadéquat pour les requêtes admin de l'équipe.
Rejeté.
Alternative D — Ne rien faire
Cons : Phase 3 incomplète, RFC 0010 sans cible.
Rejeté.
Testing plan
14 tests :
Pure :
invite_status_round_trips_through_string.build_invite_payload_includes_team_and_handle.build_accept_invite_payload_references_invite_id.
DB-mocked (sea-orm/mock) :
4. invite_remote_persists_pending_row.
5. invite_remote_uses_provided_role.
6. accept_creates_team_member_with_remote_identity_id.
7. accept_marks_invite_accepted.
8. accept_refuses_non_pending_invite.
9. reject_marks_rejected.
10. revoke_marks_revoked_when_pending.
11. revoke_marks_revoked_when_already_accepted.
12. list_pending_for_team_returns_only_pending.
team_service extension :
13. add_member_identity_local_writes_user_id_column.
14. add_member_identity_remote_writes_remote_identity_id_column.
Migration guide
### Added (rustwarden-core, feature `experimental-fediverse`) - Migration `m20260510_000015_fediverse_pending_invites`. - `FediverseInviteService` (invite_remote/accept/reject/revoke/list_pending_for_team). - `team_service::add_member_identity` + `teams_of_identity`. - Helpers `build_invite_payload`, `build_accept_invite_payload`, `build_reject_invite_payload`, `build_revoke_membership_payload`. ### Migration steps 1. `cargo run --bin migrate`. 2. Apps consommatrices : aucun changement obligatoire ; les anciennes méthodes `team_service::add_member(uuid)` continuent de fonctionner. 3. Pour activer l'invitation cross-instance, écrire les handlers HTTP (1 endpoint admin invite, 1 endpoint IdP accept/reject, 1 endpoint admin revoke) qui appellent le service.
Open questions
Toutes résolues à l'auto-revue :
-
PK
team_membersinchangée : oui, on ajoute un index unique partiel(team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULLplutôt que de modifier la PK. - TTL invitations : 7 jours par défaut (configurable plus tard).
-
Format de
activity_id:https://{domain}/invites/{uuid}, conforme master plan §4.
Tracking issue
À ouvrir au moment de la PR.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Pattern invite réutilisable au-delà du fediverse. |
| Stabilité API | 4 | Additif ; add_member_identity ne casse pas add_member. |
| Performance | 4 | Indexes adaptés, table petite (invitations éphémères). |
| Sécurité | 4 | State machine claire, idempotence du accept via le service. |
| Complexité d'implémentation | 3 | ~800 lignes (migration + entité + service + 2 méthodes team_service + 14 tests). |
| Maintenance | 3 | State machine à maintenir, mais pattern identique à RFC 0006. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust