RFC 0008 — Fediverse: abstraction Identity + colonnes sibling FK
- 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 doit représenter un acteur autorisé sur une ressource sans figer si cet acteur est local ou distant (équipes cross-instance, partages, audit). - Plan associé :
docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 3 — fondation. - Dépendances : RFC 0003 (
remote-identity-store), RFC 0007 (jwt-claims-identity-kind).
Summary
Introduire l'enum public Identity { Local(Uuid), Remote(Uuid) } + le trait IdentityResolver, puis ajouter au schéma DB les colonnes sibling FK qui permettront aux services existants (équipes, partages, audit) d'accepter un sujet distant sans casse des données existantes :
team_members.remote_identity_id UUID NULLresource_shares.shared_with_remote_identity_id UUID NULLaudit_logs.actor_remote_identity_id UUID NULLaudit_logs.target_remote_identity_id UUID NULL
L'adaptation des services pour réellement accepter un Identity::Remote (CRUD, checks de permission, invitations) reste l'objet de RFC 0009 ; cette RFC livre seulement le contrat type-safe + le schéma support.
Motivation
Aujourd'hui, team_service::add_member, resource_service::share_with_user, audit_service::log n'acceptent qu'un Uuid qui est implicitement un users.id. Phase 2 a déjà livré les sessions remote (JWT IdentityKind::Remote), mais il n'existe aucun moyen pour ces sessions d'être inscrites dans une équipe ou de figurer dans un audit log sans confusion d'identifiants.
Sans cette RFC, RFC 0009 aurait à introduire à la fois (a) le contrat Identity, (b) les colonnes sibling FK, et (c) la modification des services. Le scope serait inutilement large. En isolant (a) + (b), on dérisque la livraison.
Sans cette RFC, le code consommateur qui doit aujourd'hui faire un cross-table lookup users ↔ remote_identities à chaque check d'authz n'a aucune abstraction unifiée.
Non-goals
- Pas de modification du
team_service,resource_service,audit_service— RFC 0009. - Pas de modification de la PK composite
(team_id, user_id)duteam_members— la transformation en(team_id, user_id, remote_identity_id)avec CHECK XOR est faisable mais demande une migration data plus invasive ; déférée à RFC 0009 quand le service la consomme. - Pas de UI / API JSON d'invitations cross-instance — RFC 0009.
- Pas d'extension de
Principaldans cette RFC — le wrapper enum est introduit mais l'intégration côté handlers reste interne au master plan §4.
API proposée
Enum + trait
/// Universally unique reference to an actor known to Rustwarden. /// /// Discriminates a local user (`users.id`) from a federated remote /// identity (`remote_identities.id`) at the type level so middlewares /// and services can short-circuit confused-deputy hazards before /// touching the database. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind", content = "id")] pub enum Identity { /// `users.id`. Local(Uuid), /// `remote_identities.id`. Remote(Uuid), } impl Identity { pub fn is_local(&self) -> bool; pub fn is_remote(&self) -> bool; pub fn id(&self) -> Uuid; pub fn as_local(&self) -> Option<Uuid>; pub fn as_remote(&self) -> Option<Uuid>; } /// Look-up facade that returns a unified [`Identity`] regardless of /// whether the underlying row lives in `users` or in /// `remote_identities`. Implementations should normalise the input /// (handle vs UUID vs JWT claims) before querying. #[async_trait] pub trait IdentityResolver: Send + Sync { async fn resolve_local(&self, user_id: Uuid) -> Result<Option<Identity>, AppError>; async fn resolve_remote(&self, remote_id: Uuid) -> Result<Option<Identity>, AppError>; }
Construction depuis JWT Claims (RFC 0007)
impl Identity { /// Convenience constructor from RFC 0007 claims. pub fn from_claims(claims: &Claims) -> Self { if claims.is_remote() { Self::Remote(claims.remote_identity_id().unwrap_or(claims.sub)) } else { Self::Local(claims.sub) } } }
Migration m20260510_000014_identity_sibling_fks
-- team_members ALTER TABLE team_members ADD COLUMN remote_identity_id UUID NULL; -- chk: at least one of (user_id, remote_identity_id) is set; both is illegal. ALTER TABLE team_members ADD CONSTRAINT chk_team_members_principal CHECK ((user_id IS NOT NULL) <> (remote_identity_id IS NOT NULL)); -- resource_shares ALTER TABLE resource_shares ADD COLUMN shared_with_remote_identity_id UUID NULL; -- chk: exactly one of (user_id, team_id, remote_identity_id) is set ALTER TABLE resource_shares ADD CONSTRAINT chk_resource_shares_remote_principal CHECK ( ( (shared_with_user_id IS NOT NULL)::int + (shared_with_team_id IS NOT NULL)::int + (shared_with_remote_identity_id IS NOT NULL)::int ) = 1 ); -- audit_logs ALTER TABLE audit_logs ADD COLUMN actor_remote_identity_id UUID NULL; ALTER TABLE audit_logs ADD COLUMN target_remote_identity_id UUID NULL; -- partial indexes for the lookup paths that RFC 0009 will exercise CREATE INDEX team_members_remote_identity_idx ON team_members (remote_identity_id) WHERE remote_identity_id IS NOT NULL; CREATE INDEX resource_shares_remote_identity_idx ON resource_shares (shared_with_remote_identity_id) WHERE shared_with_remote_identity_id IS NOT NULL;
Note : la modification de la PK composite
team_members(team_id, user_id)n'est pas faite ici car (1) elle exige un drop+recreate qui touche les rows existantes, (2) le serviceteam_servicene consomme pas encore l'identité distante. Quand RFC 0009 ajoute les chemins distants au service, la PK sera adaptée par une migration sœur à ce moment-là, idempotente.
Modèles SeaORM
Les Models existants (team_member::Model, resource_share::Model, audit_log::Model) gagnent un champ Option<Uuid> chacun, gated derrière la feature experimental-fediverse est inutile car les colonnes existeront en DB pour tout le monde — donc le champ est exposé en permanence côté Rust (feature-flag uniquement la logique distante).
L'ActiveModel existant doit être étendu avec un NotSet par défaut là où des structs literals sont utilisées (cas équivalent à RFC 0005 sur users).
Analyse de compatibilité
- Non-breaking : 4 colonnes nullable + 2 indexes partiels.
-
CHECK constraints permissifs : autorisent l'état actuel (toutes lignes ont
user_idouteam_idnon-NULL). - Migration up/down réversible (DROP COLUMN + DROP CONSTRAINT + DROP INDEX).
- Aucun service public actuel n'est modifié.
- Aucune API publique cassée — l'enum/trait sont additifs.
Alternatives considérées
Alternative A (recommandée) — Sibling columns + enum + trait
Pros : non-breaking, atomique, RFC 0009 peut consommer immédiatement.
Cons : duplication temporaire (2 colonnes sibling pour 1 concept logique) tant que la migration de PK n'est pas faite.
Alternative B — Colonne unique polymorphe actor_kind + actor_id
Une seule colonne actor_id UUID + une colonne actor_kind TEXT.
Pros : schéma compact.
Cons :
- Pas de FK référentielle (CASCADE manquante).
- Plus difficile à indexer pour les queries actuelles.
- Tous les CHECK existants à réécrire.
Rejeté.
Alternative C — Table principals avec FK depuis tout
Pros : modèle propre.
Cons : énorme refacto, casse la rétrocompat, ralentit toutes les requêtes existantes.
Rejeté.
Alternative D — Ne rien faire
Cons : RFC 0009 doit absorber tout le scope ; les services existants ne pourront jamais accepter un remote sans cette fondation.
Rejeté.
Testing plan
8 tests :
Pure (enum + helpers) :
identity_local_helpers—Identity::Local(uuid).is_local() == true,is_remote() == false,id()retourne le uuid,as_local()retourneSome,as_remote()retourneNone.identity_remote_helpers— symétrique.identity_serialises_with_kind_and_id— JSON{"kind":"local","id":"<uuid>"}round-trip.identity_from_claims_local— un JWT local produitIdentity::Local(claims.sub).identity_from_claims_remote— un JWT remote produitIdentity::Remote(claims.remote_id.unwrap()).identity_eq_hash— deuxIdentityégales hashent au même bucket (smoke test pourHashMapfuturs).
Migration smoke :
7. migration_adds_remote_identity_id_to_team_members — appliquer up + vérifier que INSERT INTO team_members (team_id, user_id, ...) fonctionne et que INSERT ... (team_id, remote_identity_id) fonctionne aussi (CHECK respecté).
8. migration_check_constraint_rejects_both_user_and_remote — INSERT avec les deux non-NULL doit échouer (Postgres).
Les tests 7-8 sont des intégration tests et requièrent une DB ; ils sont gardés derrière #[ignore] pour ne pas casser le CI mock-only par défaut, mais peuvent être lancés via cargo test -- --ignored quand DATABASE_URL est dispo.
Migration guide
### Added (rustwarden-core) - Migration `m20260510_000014_identity_sibling_fks`. - `Identity` enum + `IdentityResolver` trait sous `rustwarden_core::identity` (gated `experimental-fediverse`). - Champs `Option<Uuid>` ajoutés à `team_member::Model`, `resource_share::Model`, `audit_log::Model`. ### Migration steps 1. `cargo run --bin migrate`. 2. Aucune action côté apps consommatrices : les colonnes sont nullable et toutes les requêtes existantes filtrent implicitement sur `user_id IS NOT NULL`. 3. RFC 0009 introduira l'usage actif de ces colonnes pour les invitations cross-instance.
Open questions
Toutes résolues à l'auto-revue :
-
Localisation
Identity: nouveau modulerustwarden_core::identity(pas dansauthnimodels, sa portée est multi-couche). -
Identity::id(): retourne leUuidbrut sans discrimination de variante — utile pour les indexations DB ; les helpersas_local/as_remotecouvrent le besoin sécurisé. -
Variante avec quatre champs
Local{user_id, username}: rejetée pour simplicité ; le username est récupéré viaIdentityResolverquand nécessaire. -
Casse de la PK
team_members: non, défère à RFC 0009.
Tracking issue
À ouvrir au moment de la PR.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 5 | Identity est l'abstraction centrale qui permettra à tout service d'accepter remote sans cassure. |
| Stabilité API | 4 | Additif derrière feature ; 3 entités gagnent un champ optionnel. |
| Performance | 4 | Indexes partiels limitent l'overhead aux rows distantes. |
| Sécurité | 4 | CHECK XOR au niveau DB empêche la pollution silencieuse ; le trait IdentityResolver reste en charge des checks. |
| Complexité d'implémentation | 4 | ~400 lignes (migration + enum + trait + 8 tests). |
| Maintenance | 3 | Duplication colonnes sibling temporaire jusqu'à la livraison de RFC 0009. |
| Documentation | 2 | Rustdoc EN, doctest ; pas d'examples/ complet. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust