0009-team-resource-cross-instance.md 10723 octets

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-core qui 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-ui ajoutera 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_member existante reste inchangée et continue d'accepter Uuid brut.
  • 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 :

  1. invite_status_round_trips_through_string.
  2. build_invite_payload_includes_team_and_handle.
  3. 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_members inchangée : oui, on ajoute un index unique partiel (team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULL plutô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èreNote (1-5)Commentaire
Généralité4Pattern invite réutilisable au-delà du fediverse.
Stabilité API4Additif ; add_member_identity ne casse pas add_member.
Performance4Indexes adaptés, table petite (invitations éphémères).
Sécurité4State machine claire, idempotence du accept via le service.
Complexité d'implémentation3~800 lignes (migration + entité + service + 2 méthodes team_service + 14 tests).
Maintenance3State machine à maintenir, mais pattern identique à RFC 0006.
Documentation4Rustdoc EN, doctest, CHANGELOG, migration guide.
Total26/35≥ 25 → Accepted