0008-identity-abstraction.md 11645 octets

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-core qui 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 NULL
  • resource_shares.shared_with_remote_identity_id UUID NULL
  • audit_logs.actor_remote_identity_id UUID NULL
  • audit_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) du team_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 Principal dans 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 service team_service ne 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_id ou team_id non-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) :

  1. identity_local_helpers — Identity::Local(uuid).is_local() == true, is_remote() == false, id() retourne le uuid, as_local() retourne Some, as_remote() retourne None.
  2. identity_remote_helpers — symétrique.
  3. identity_serialises_with_kind_and_id — JSON {"kind":"local","id":"<uuid>"} round-trip.
  4. identity_from_claims_local — un JWT local produit Identity::Local(claims.sub).
  5. identity_from_claims_remote — un JWT remote produit Identity::Remote(claims.remote_id.unwrap()).
  6. identity_eq_hash — deux Identity égales hashent au même bucket (smoke test pour HashMap futurs).

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 module rustwarden_core::identity (pas dans auth ni models, sa portée est multi-couche).
  • Identity::id() : retourne le Uuid brut sans discrimination de variante — utile pour les indexations DB ; les helpers as_local/as_remote couvrent le besoin sécurisé.
  • Variante avec quatre champs Local{user_id, username} : rejetée pour simplicité ; le username est récupéré via IdentityResolver quand 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èreNote (1-5)Commentaire
Généralité5Identity est l'abstraction centrale qui permettra à tout service d'accepter remote sans cassure.
Stabilité API4Additif derrière feature ; 3 entités gagnent un champ optionnel.
Performance4Indexes partiels limitent l'overhead aux rows distantes.
Sécurité4CHECK XOR au niveau DB empêche la pollution silencieuse ; le trait IdentityResolver reste en charge des checks.
Complexité d'implémentation4~400 lignes (migration + enum + trait + 8 tests).
Maintenance3Duplication colonnes sibling temporaire jusqu'à la livraison de RFC 0009.
Documentation2Rustdoc EN, doctest ; pas d'examples/ complet.
Total26/35≥ 25 → Accepted