RFC 0029 — RemoteResourceShareService dans rustwarden-fediverse
- Auteur : EBO (gitrust)
- Date : 2026-05-15
- Statut : Accepted (33/35)
- Origine : RFC 0001 du tracking gitrust (docs/rfcs/gitrust/0001-fediverse-remote-resource-shares.md)
- Apps consommatrices concernées : gitrust (immédiat), tout futur consommateur Rustwarden qui active
experimental-fediverseet veut autoriser des identités remote sur ses ressources métier. - Crate cible :
rustwarden-fediverse(PASrustwarden-core) - Évaluation détaillée : docs/plans/2026-05-15-rfcs-gitrust-evaluation.md
Summary
Ajouter à rustwarden-fediverse un service RemoteResourceShareService + une table fediverse_resource_shares qui permet d'attribuer une permission sur une ressource générique (identifiée par resource_type + resource_id) à une remote_identities.id. Le service vit dans le crate fédération, sans toucher au ResourceService ni au schéma resource_shares de rustwarden-core.
Motivation
Problème concret
Les RFCs 0001-0028 du framework ont livré une infrastructure d'identité fédérée complète : WebFinger, Actor, JWT avec identity_kind: Remote, revocation, invites de teams cross-instance. Mais un user remote ne peut être collaborateur d'aucune ressource métier non-team :
- Dans gitrust : Owner d'un repo ne peut pas inviter
@alice@autre-instance.orgcomme Developer sur son repository (autre qu'à travers une team). - Dans un futur consommateur (par exemple un outil de gestion de documents) : impossible de partager un document avec un user remote sans le faire passer par une team.
Aujourd'hui, rustwarden-core::ResourceService connaît Principal::{User, Team} mais pas Remote. Le champ resource_share::shared_with_remote_identity_id existe dans le modèle SeaORM (crates/rustwarden-core/src/models/resource_share.rs:26-30) mais n'est ni matérialisé en SQL, ni consommé par share_with (toujours set à None, ligne 234 de resource_service.rs), ni consulté par effective_permission_for_user. C'est un stub mort.
Impact si on ne fait rien
- Tout consommateur de
experimental-fediversequi veut une collaboration cross-instance au-delà des teams est forcé d'écrire un workaround local (table dédiée + service + refactor d'autorisation). Trois workarounds en parallèle dans trois consommateurs = dette duplicate × 3 et risques de divergence sécuritaire. - Le SSO fédéré perd 80 % de son intérêt fonctionnel :
@alice@…peut se logger, mais ne peut rien faire d'utile sauf rejoindre une team.
Pourquoi un nouveau service dans fediverse plutôt qu'étendre ResourceService du core ?
C'est la question centrale, plusieurs raisons :
-
Séparation des préoccupations explicite (choix architectural existant du framework). Les crates fédération sont isolés : leur activation est optionnelle (feature
experimental-fediverse), leur schéma est tracké séparément (seaql_fediverse_migrations), leurs migrations vivent danscrates/rustwarden-fediverse/migration/. ÉtendreResourceServicecasserait cette frontière en injectant un concept fédération dans le crate core. -
Stabilité du core.
rustwarden-coreest en cycle de release stable. AjouterPrincipal::Remoteimpose un breaking minor (nouveau variant exhaustivement matché par tous les consommateurs qui pattern-match surPrincipal). Ajouter un service séparé dans fediverse n'impose rien au consommateur qui n'active pas la fédération. -
Cycle de vie API.
experimental-fediverseautorise des breaking sans bump major (cf. processus §5). C'est exactement le bon endroit pour itérer sur ce nouveau service tant qu'il n'a pas atteint la maturité requise pour la promotion stable. -
Composabilité. Le consommateur orchestre la résolution finale de permission en combinant
ResourceService::effective_permission_for_user(Local)+RemoteResourceShareService::effective_role_for_remote_identity(Remote). Cette composition est triviale (max sur permission level) et reste maîtrisée par le consommateur, qui connaît son modèle d'autorisation.
Urgence
Non bloquant. Le consommateur gitrust peut implémenter un workaround local (table gitrust_remote_collaborators) en attendant. Mais chaque jour sans la solution upstream est une journée de dette qui sera à résorber. Idéalement livré dans la prochaine minor rustwarden-fediverse (cycle 2 semaines selon processus §9).
Non-goals
- Ne PAS modifier
rustwarden-core::ResourceService. Pas de variantPrincipal::Remote, pas de migration surresource_shares, pas de nouveau paramètre dans les API existantes. - Ne PAS toucher au champ orphelin
shared_with_remote_identity_iddansresource_share::Model. Il est cosmétique et peut rester en place ou être supprimé par une RFC séparée — hors scope. - Ne PAS introduire de notion d'ownership remote. Un owner reste un user local. Le remote ne peut être que collaborateur (read/write/admin).
- Ne PAS gérer la fédération outbound des partages. Émettre un
Announce{Add}AP quand un partage est créé, ou propager unRemovesur revocation, c'est l'objet d'une RFC ultérieure (peut être empilée sur celle-ci). Cette RFC se limite à stocker et résoudre les permissions remote. - Ne PAS gérer les invites cross-instance. C'est
fediverse_invite_serviceetfediverse_pending_invitesqui en sont responsables (RFC 0009/0014). Les deux services sont complémentaires : l'invite gère le "consentement préalable", le share gère l'autorisation effective. Selon les flux applicatifs, le consommateur peut exiger qu'un accept d'invite crée automatiquement un share, ou pas.
API proposée
Migration SQL (nouvelle migration m20260516_000002_remote_resource_shares.rs)
CREATE TABLE fediverse_resource_shares ( id UUID PRIMARY KEY, resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE, remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE, permission_level VARCHAR(32) NOT NULL, -- "read" | "write" | "admin" shared_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX idx_fediverse_resource_shares_unique ON fediverse_resource_shares (resource_id, remote_identity_id); CREATE INDEX idx_fediverse_resource_shares_remote ON fediverse_resource_shares (remote_identity_id);
FK sur resources(id) : la table référence l'entrée du registry framework (resources table de core), pas la ressource concrète (repositories côté gitrust). Cohérent avec le pattern de resource_shares. Le crate fediverse dépend déjà de rustwarden-core (cf. Cargo.toml).
Pas de CHECK constraint à plusieurs colonnes : table monolithique, un share = un (resource, remote_identity) pair. Simple et lisible.
Modèle SeaORM (crates/rustwarden-fediverse/src/models/fediverse_resource_share.rs)
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "fediverse_resource_shares")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub resource_id: Uuid, pub remote_identity_id: Uuid, pub permission_level: String, pub shared_by_user_id: Uuid, pub created_at: DateTime<Utc>, }
Service (crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs)
use crate::error::FediverseError; use rustwarden_core::error::AppError; use rustwarden_core::services::resource_service::ResourceService; use sea_orm::DatabaseConnection; use uuid::Uuid; /// Permission levels (must mirror `rustwarden_core::services::resource_service::PERMISSION_HIERARCHY`). const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"]; /// Sharing of generic application resources with remote ActivityPub identities. /// /// Companion of [`rustwarden_core::services::resource_service::ResourceService`] /// but scoped to `remote_identities`. The consumer composes the two services /// when resolving the effective permission of an [`rustwarden_core::identity::Identity`]. pub struct RemoteResourceShareService; impl RemoteResourceShareService { /// Share a resource with a remote identity at the given permission level. /// /// # Errors /// /// - [`AppError::NotFound`] if the resource registry entry does not exist. /// - [`AppError::Validation`] if `permission_level` is unknown. /// - [`AppError::Database`] on persistence error. pub async fn share( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, remote_identity_id: Uuid, permission_level: &str, shared_by_user_id: Uuid, ) -> Result<fediverse_resource_share::Model, AppError> { // 1. Look up resources registry entry (composition with core). let entry = ResourceService::find_by_type_and_id(db, resource_type, resource_id) .await? .ok_or_else(|| AppError::NotFound("Resource not found".into()))?; // 2. Validate permission level. if !PERMISSION_HIERARCHY.contains(&permission_level) { return Err(AppError::Validation(format!( "Invalid permission level: {}", permission_level ))); } // 3. Upsert by (resource_id, remote_identity_id). // Implementation détaillée — voir tests pour le contrat. // ... } /// Revoke a remote share by its (resource, remote_identity) pair. pub async fn unshare( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, remote_identity_id: Uuid, ) -> Result<bool, AppError> { /* ... */ } /// Resolve the effective permission level of a remote identity on a resource. /// /// Returns `None` if no share exists. /// Returns the permission level (`"read" | "write" | "admin"`) otherwise. /// /// The consumer composes this with /// [`ResourceService::effective_permission_for_user`] when resolving an /// [`rustwarden_core::identity::Identity::Remote`]. pub async fn effective_permission_for_remote_identity( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, remote_identity_id: Uuid, ) -> Result<Option<String>, AppError> { /* ... */ } /// List all remote shares for a resource (for admin display). pub async fn list_shares( db: &DatabaseConnection, resource_type: &str, resource_id: Uuid, ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { /* ... */ } /// List all resources shared with a given remote identity (for "my access" page). pub async fn list_for_remote_identity( db: &DatabaseConnection, remote_identity_id: Uuid, ) -> Result<Vec<fediverse_resource_share::Model>, AppError> { /* ... */ } }
Exemple d'usage côté consommateur
// gitrust-core/src/services/repository_service.rs use rustwarden_core::identity::Identity; use rustwarden_core::services::resource_service::ResourceService; use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService; pub async fn effective_role_for_identity( db: &DatabaseConnection, repo_id: Uuid, identity: Identity, ) -> Result<Option<Role>, AppError> { let level = match identity { Identity::Local(user_id) => { ResourceService::effective_permission_for_user( db, "repository", repo_id, user_id, ).await? } Identity::Remote(remote_id) => { RemoteResourceShareService::effective_permission_for_remote_identity( db, "repository", repo_id, remote_id, ).await? } }; Ok(level.map(Role::from_permission_str)) }
Note : la composition max(local, remote) n'a pas de sens ici puisque l'identity est soit local soit remote, jamais les deux. Le consommateur n'a pas besoin de combiner.
Analyse de compatibilité
- API existante non modifiée → non-breaking (patch/minor possible).
-
Nouvelle API ajoutée :
RemoteResourceShareService(5 méthodes) + nouveau modèle SeaORM. -
Schéma DB : nouvelle migration additive dans
FediverseMigrator. Aucun ALTER sur table existante. - Format sérialisé inchangé : pas de modification des cookies, JWT claims, events JSON.
-
Pattern matching consommateurs : aucun enum modifié. Le pattern match sur
Principalcôtérustwarden-corereste exhaustif. - Doc rustdoc : tout item public livré avec doctest exécutable.
Verdict : 100 % additif, mergeable en minor rustwarden-fediverse sans risque pour les consommateurs existants.
Alternatives considérées
Alternative A (recommandée) : nouveau service dans rustwarden-fediverse
Pros :
- Respecte la séparation core/fédération posée par l'architecture du framework.
- Aucun breaking change.
- Cycle de vie API isolé sur
experimental-fediversejusqu'à promotion stable. - Composition simple côté consommateur (un match sur
Identity). - Aucun consommateur "non-fediverse" n'est impacté ni à la compile ni au runtime.
Cons :
- Le consommateur doit faire la composition lui-même (vs. une API unifiée côté core).
- Si une 4ème catégorie de principal apparaît (ex: API key), la composition côté consommateur devient un match à 4 branches.
Alternative B : étendre rustwarden-core::Principal avec Remote(Uuid)
Pros :
- API unifiée côté consommateur : un seul
effective_permission_for_user/identity_can_access.
Cons :
- Casse la séparation :
rustwarden-coredevient conscient du concept "remote identity" alors qu'il n'a aucun moyen de fetcher / valider / révoquer une remote_identity. - Breaking minor : tous les
match Principal { … }dans les consommateurs doivent ajouter une branche. - Migration
ALTER TABLE resource_sharesavec CHECK constraint à 3 termes : plus complexe à reverter. - Couple le cycle de release : pour ajouter ou faire évoluer le concept Remote, il faut une release core même quand seul fediverse change.
- Le consommateur sans fediverse paie quand même le coût (pattern match exhaustif obligatoire) pour une feature qu'il n'utilise pas.
Alternative C : ne rien faire upstream, workaround dans chaque consommateur
Pros : zéro coût upstream, zéro risque côté framework.
Cons :
- Dette dupliquée dans chaque consommateur qui voudra de la collaboration cross-instance.
- Risque de divergence sécuritaire (un consommateur applique la check, un autre non).
- Difficile à promouvoir
experimental-fediverseen stable si un cas d'usage majeur reste hors framework. - Pour gitrust spécifiquement : ~8j de workaround + ~3j de retrait quand la solution arrive upstream = ~11j, contre ~5j si upstream livre directement.
Alternative D : trait RemoteResourceShareProvider dans core, impl dans fediverse
Pros :
- Core expose une abstraction qui peut être implémentée par fediverse OU par n'importe quel autre provider d'identité distribuée future (matrix, OIDC, …).
Cons :
- Sur-ingénierie pour 1 seul provider identifié.
- Le trait dans core devrait pré-déclarer un type
RemoteIdopaque, ce qui complique l'API. - Bénéfice théorique seulement : aucun autre provider d'identité distribuée n'est sur la roadmap.
Choix : Alternative A. C'est la plus économique en coût aujourd'hui ET la plus propre architecturalement. L'Alternative D pourra être adoptée plus tard (refactor en interne) sans casser l'API publique livrée par A.
Testing plan
Unit (obligatoire)
Dans crates/rustwarden-fediverse/src/services/remote_resource_share_service.rs#tests :
- Happy path × méthode (5 méthodes × 1 cas = 5 tests)
- Cas d'erreur par méthode (minimum 3 par méthode publique) :
share: resource_id inexistant →NotFoundshare: permission_level invalide →Validationshare: remote_identity_id inexistant →Database(FK violation)share× 2 sur même pair → upsert, pas duplicateunsharesur pair inexistant → retournefalse, pas d'erreureffective_permission_for_remote_identity: aucun share →Noneeffective_permission_for_remote_identity: share présent → renvoie levellist_sharessur resource sans share →Vec::new()list_for_remote_identitysur identity sans share →Vec::new()
Doctest (obligatoire)
Exemple share + effective_permission_for_remote_identity dans le doc comment de struct. Marqué no_run (touche la DB).
Integration (recommandé)
Test avec SQLite in-memory ou sea-orm-mock pour vérifier que la composition core (ResourceService::find_by_type_and_id) + fediverse (share) fonctionne bout en bout.
Compat (obligatoire)
Test que rustwarden-fediverse compile sans warning avec et sans la feature fediverse-resource-shares (si jamais on flag, sinon directement par défaut).
Test que cargo test -p rustwarden-core reste vert (rien ne doit changer côté core).
Bench
Non requis : la table est en lecture seule sur le hot path (résolution permission, indexée). Pas de regression attendue.
Fuzz
Non requis : pas de parsing externe (les inputs sont des UUIDs typés, déjà sanitized par le framework HTTP).
Migration guide
À copier dans le CHANGELOG.md de rustwarden-fediverse au merge :
Added (0.X+1.0)
RemoteResourceShareService(services/remote_resource_share_service.rs) — partager une ressource avec une identité ActivityPub remote, via 5 méthodes :share,unshare,effective_permission_for_remote_identity,list_shares,list_for_remote_identity.- Nouveau modèle
models::fediverse_resource_share::Model. - Migration
m20260516_000002_remote_resource_sharesajoutée àFediverseMigrator.
Migration depuis l'absence
Aucune. Première livraison de l'API. Les consommateurs qui ne l'utilisent pas ne paient aucun coût.
Exemple d'adoption côté consommateur
Avant (workaround local nécessaire) :
// Table locale gitrust_remote_collaborators dans le consommateur struct LocalRemoteCollabService; // ~200 LOC de duplication
Après (0.X+1.0) :
use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService; RemoteResourceShareService::share( &db, "repository", repo_id, remote_id, "write", admin_user_id, ).await?;
Steps pour gitrust spécifiquement
- Bump
rustwarden-fediversedansCargo.toml. - Migrer les rows de
gitrust_remote_collaboratorsversfediverse_resource_shares:INSERT INTO fediverse_resource_shares (id, resource_id, remote_identity_id, permission_level, shared_by_user_id, created_at) SELECT gen_random_uuid(), (SELECT id FROM resources WHERE resource_type='repository' AND resource_id = grc.repository_id), grc.remote_identity_id, CASE grc.role WHEN 'reader' THEN 'read' WHEN 'developer' THEN 'write' WHEN 'maintainer' THEN 'admin' WHEN 'owner' THEN 'admin' END, grc.created_by, grc.created_at FROM gitrust_remote_collaborators grc;
- Refactor
RepositoryService::effective_rolepour appeler le service framework. - Supprimer
RemoteCollaboratorServicelocal et la tablegitrust_remote_collaborators. - Section "RÉSOLU UPSTREAM" dans
docs/UPSTREAM_RUSTWARDEN.md.
Open questions
-
Audit log : le service doit-il appeler
on_audit_eventdu framework àshare/unshare? (Probablement oui, cohérent avecResourceService::share_with.) -
Hook
on_resource_remote_shared/on_resource_remote_unshared: utile pour qu'un consommateur émette unAnnounce{Add}AP outbound ? À discuter — peut être empilé dans une RFC 0002 dédiée. - Listing combiné : faut-il une méthode helper qui retourne toutes les permissions (locales + remote) sur une ressource pour l'UI admin ? Probablement plus propre côté consommateur (qui maîtrise son rendu).
-
Cascading sur
Delete{Actor}: quandFediverseSignalService::apply_delete_actordésactive uneremote_identity, faut-il supprimer ses shares ? Aujourd'hui leON DELETE CASCADEde la FK le ferait — mais on ne supprime pas physiquement la remote_identity (soft-delete viadisabled_at). À clarifier dans la doc du service ou via une méthodepurge_for_disabled_identities.
Score sur la grille du processus §4
| Critère | Note | Justification |
|---|---|---|
| Généralité | 5 | Sert tout consommateur AP, pas une logique app-specific |
| Stabilité API | 5 | Non-breaking par ajout pur, zéro impact sur consommateurs sans fediverse |
| Performance | 5 | Pas de regression possible (nouvelle table indexée) |
| Sécurité | 4 | Ferme un gap d'autorisation cross-instance (fonctionnalité, pas un fix) |
| Complexité impl | 4 | < 200 LOC service + 1 migration + 1 modèle SeaORM, patterns existants |
| Maintenance | 5 | Zéro nouvelle dépendance, composition de briques stables |
| Documentation | 5 | Rustdoc + doctest + entrée CHANGELOG + ce guide migration |
| Total | 33/35 | ≥ 30 = accélération possible sans second avis |
Tracking issue
À ouvrir sur le repo rustwarden-core (où vit la canonical RFC dir) avec :
- Label
rfc+area:fediverse - Copie du Summary + lien vers ce fichier (côté gitrust pour l'instant).
- Mention de gitrust comme premier consommateur prêt à adopter dès la livraison.
Historique :
- 2026-05-15 : Draft initial rédigé côté gitrust suite à l'identification du gap pendant la planification de l'intégration fediverse.
GitRust