0030-fediverse-remote-personal-access-tokens.md 16969 octets

RFC 0030 — RemotePersonalAccessTokenService dans rustwarden-fediverse

Summary

Ajouter à rustwarden-fediverse un service RemotePersonalAccessTokenService + table fediverse_personal_access_tokens qui permet à une remote_identities.id d'émettre, lister et révoquer ses propres Personal Access Tokens locaux à l'instance courante. Le service vit dans le crate fédération, sans toucher au PersonalTokenService<S> ni au schéma personal_access_tokens de rustwarden-core. Symétrie complète avec la RFC 0001.

Motivation

Contexte fonctionnel

Le modèle d'identité fédérée livré par les RFCs 0001-0028 fait qu'une identité remote (@alice@autre-instance.org) peut :

  • ✅ S'authentifier sur l'instance locale via SSO (RFC 0006/0013).
  • ✅ Être membre de teams cross-instance (RFC 0009 — sous réserve de la matérialisation team_members.remote_identity_id, cf. RFC 0003).
  • ✅ Être collaboratrice d'une ressource (sous réserve de la livraison RFC 0001).
  • ❌ Émettre un Personal Access Token local pour authentifier ses opérations git HTTPS / API.

Le scénario type côté gitrust :

1. alice se logge sur gitrust-B via SSO fédéré → session web active.
2. alice veut faire `git push https://alice:TOKEN@gitrust-b.test/bob/widgets.git`.
3. alice va sur /settings/tokens, génère un PAT scopé "repo:write".
4. Échec actuel : `personal_access_tokens.user_id` est NOT NULL FK users(id).
   alice n'a pas d'entrée dans users, son identité vit dans remote_identities.

Sans PAT remote, l'utilisateur autonome (cf. modèle "user remote = collaborateur autonome" établi pour gitrust) ne peut pas utiliser git via HTTPS. Reste l'option SSH avec clé locale (à traiter séparément, gap local gitrust), mais la couverture HTTPS reste manquante.

Pourquoi un nouveau service dans fediverse, pas étendre le PersonalTokenService du core ?

Mêmes arguments que la RFC 0001 :

  1. Séparation core/fédération : rustwarden-core::PersonalTokenService<S: TokenScope> est générique et stable. Ajouter une notion de "remote identity owner" l'oblige à connaître un concept fédération qu'il ne devrait pas connaître.
  2. Stabilité API : le trait TokenScope du framework est exposé aux consommateurs. Ajouter un paramètre owner: Principal casserait toutes les implémentations downstream.
  3. Cycle de vie : experimental-fediverse permet l'itération libre tant qu'on n'a pas atteint la promotion stable.
  4. Composabilité : le consommateur fait son lookup côté DB selon l'identité présentée (PAT local OU PAT remote, via le préfixe du token ou un flag) puis appelle le service approprié.

Urgence

Non bloquant, mais paire fonctionnelle avec la RFC 0001 : sans PAT remote, le scénario "user remote autonome" reste boiteux (SSH ok, HTTPS pas ok). Idéalement livré dans la même minor rustwarden-fediverse que la RFC 0001 pour une expérience consommateur cohérente.

Non-goals

  • Ne PAS modifier rustwarden-core::PersonalTokenService<S> ni le schéma personal_access_tokens.
  • Ne PAS récupérer un PAT existant d'une autre instance (e.g. via un protocole AP de propagation de credentials). Chaque instance émet ses propres PAT pour les identités qu'elle reconnaît. Cohérent avec le modèle "autonomie locale".
  • Ne PAS unifier le format/préfixe des PAT locaux et remote. Le service remote peut utiliser un préfixe distinct (rwfed_pat_ au lieu de rw_pat_) pour faciliter le routing côté consommateur. À discuter en Open Question.
  • Ne PAS gérer la révocation par cascade Delete{Actor} dans cette RFC : à empiler dans une RFC dédiée hooks lifecycle (ou via ON DELETE CASCADE sur la FK remote_identity_id, qui couvre l'essentiel).

API proposée

Migration SQL (nouvelle migration m20260516_000003_remote_pat.rs)

CREATE TABLE fediverse_personal_access_tokens (
    id UUID PRIMARY KEY,
    remote_identity_id UUID NOT NULL REFERENCES remote_identities(id) ON DELETE CASCADE,
    name VARCHAR(64) NOT NULL,
    token_hash VARCHAR(64) NOT NULL UNIQUE,        -- SHA-256 hex, comme PersonalTokenService
    scope TEXT NOT NULL,                            -- format identique au PersonalTokenService<S>::serialize
    expires_at TIMESTAMPTZ NULL,
    last_used_at TIMESTAMPTZ NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    revoked_at TIMESTAMPTZ NULL
);

CREATE INDEX idx_fediverse_pat_remote_identity
    ON fediverse_personal_access_tokens (remote_identity_id);
CREATE INDEX idx_fediverse_pat_active
    ON fediverse_personal_access_tokens (token_hash) WHERE revoked_at IS NULL;

Sémantique des colonnes alignée 1:1 sur la table framework personal_access_tokens (hash SHA-256, scope sérialisé, soft-revocation via revoked_at). Différences :

  • remote_identity_id au lieu de user_id.
  • Pas de FK vers users(id).

Modèle SeaORM (crates/rustwarden-fediverse/src/models/fediverse_personal_access_token.rs)

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "fediverse_personal_access_tokens")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,
    pub remote_identity_id: Uuid,
    pub name: String,
    pub token_hash: String,
    pub scope: String,
    pub expires_at: Option<DateTime<Utc>>,
    pub last_used_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub revoked_at: Option<DateTime<Utc>>,
}

Service (crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs)

use rustwarden_core::error::AppError;
use rustwarden_core::services::personal_token_service::TokenScope;
use sea_orm::DatabaseConnection;
use uuid::Uuid;

/// Personal access tokens emitted by the local instance for ActivityPub remote
/// identities. Companion of [`rustwarden_core::services::personal_token_service::PersonalTokenService`]
/// but scoped to `remote_identities` instead of `users`.
///
/// Generic over a [`TokenScope`] type so consumers can reuse their existing
/// scope definitions (e.g. gitrust's repository/git scopes) for remote PATs.
pub struct RemotePersonalAccessTokenService<S: TokenScope>(std::marker::PhantomData<S>);

impl<S: TokenScope> RemotePersonalAccessTokenService<S> {
    /// Issue a new PAT for a remote identity.
    ///
    /// Returns the **plaintext token** (one-time display). The DB stores only
    /// the SHA-256 hash. Token format: `rwfed_pat_<43 base32 chars>`.
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if `remote_identity_id` does not exist or is disabled.
    /// - [`AppError::Validation`] if `name` is empty or > 64 chars.
    /// - [`AppError::Database`] on persistence error.
    pub async fn create(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
        name: &str,
        scope: S,
        expires_at: Option<DateTime<Utc>>,
    ) -> Result<(fediverse_personal_access_token::Model, String), AppError> { /* ... */ }

    /// Verify a plaintext token and return the (model, scope) if valid + active.
    ///
    /// Constant-time hash comparison via `subtle`. Updates `last_used_at` on success.
    ///
    /// Returns `None` if:
    /// - Token format is invalid.
    /// - No matching `token_hash`.
    /// - Token is revoked or expired.
    /// - The associated `remote_identity` is disabled.
    pub async fn verify(
        db: &DatabaseConnection,
        plaintext_token: &str,
    ) -> Result<Option<(fediverse_personal_access_token::Model, S)>, AppError> { /* ... */ }

    /// Revoke a token by its id.
    pub async fn revoke(
        db: &DatabaseConnection,
        token_id: Uuid,
    ) -> Result<bool, AppError> { /* ... */ }

    /// List all (non-revoked) tokens for a remote identity (UI display).
    pub async fn list_for_remote_identity(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
    ) -> Result<Vec<fediverse_personal_access_token::Model>, AppError> { /* ... */ }

    /// Revoke all tokens for a remote identity (called on Delete{Actor}).
    pub async fn revoke_all_for_remote_identity(
        db: &DatabaseConnection,
        remote_identity_id: Uuid,
    ) -> Result<u64, AppError> { /* ... */ }
}

Exemple d'usage côté consommateur (gitrust)

// Création d'un PAT depuis /settings/tokens pour un user remote
let claims: Claims = /* extraite de la session JWT */;
let identity = Identity::from_claims(&claims);

let scope = GitrustScope::repo_write(repo_id);  // scope app-specific

let (model, plaintext) = match identity {
    Identity::Local(user_id) => {
        let (m, p) = PersonalTokenService::<GitrustScope>::create(
            db, user_id, "deploy", scope, None,
        ).await?;
        (m.id, p)
    }
    Identity::Remote(remote_id) => {
        let (m, p) = RemotePersonalAccessTokenService::<GitrustScope>::create(
            db, remote_id, "deploy", scope, None,
        ).await?;
        (m.id, p)
    }
};

// Verify côté handler git push HTTPS
// Discrimination par préfixe du token (rw_pat_ vs rwfed_pat_)
let result = if plaintext.starts_with("rwfed_pat_") {
    RemotePersonalAccessTokenService::<GitrustScope>::verify(db, &plaintext).await?
        .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s))
} else {
    PersonalTokenService::<GitrustScope>::verify(db, &plaintext).await?
        .map(|(m, s)| (Identity::Local(m.user_id), s))
};

Le consommateur orchestre proprement les deux services via le préfixe du token, sans aucun changement côté framework core.

Analyse de compatibilité

  • API existante non modifiée → non-breaking.
  • Nouvelle API ajoutée : RemotePersonalAccessTokenService<S> (5 méthodes) + nouveau modèle SeaORM.
  • Schéma DB : nouvelle table dans FediverseMigrator. Aucun ALTER sur table existante.
  • Format sérialisé : préfixe rwfed_pat_ distinct du rw_pat_ framework → pas de collision possible.
  • Trait TokenScope du framework : réutilisé tel quel, aucune modification.

Verdict : 100 % additif, mergeable en minor rustwarden-fediverse.

Alternatives considérées

Alternative A (recommandée) : table + service séparés dans fediverse

Voir API proposée ci-dessus. Cf. Pros/Cons RFC 0001 §"Alternatives" — l'analyse est isomorphe.

Alternative B : étendre personal_access_tokens avec sibling FK remote_identity_id

Pros :

  • Une seule table à interroger pour vérifier un PAT (pas de discrimination par préfixe).

Cons :

  • Modification du schéma core → breaking migration.
  • Le PersonalTokenService<S> du framework devrait prendre un Principal au lieu d'un Uuid → breaking API.
  • Le préfixe de token unique deviendrait trompeur (un même préfixe rw_pat_ couvrirait deux populations distinctes).
  • Couple le cycle de release des deux concepts.

Alternative C : pas de PAT remote, ne supporter que SSH local

Pros : zéro travail upstream, zéro nouveau service.

Cons :

  • Brise la parité fonctionnelle entre user local et user remote (le local peut générer des PAT, le remote non).
  • Empêche les workflows CI/CD remote d'utiliser HTTPS+token (pattern le plus courant).
  • Force chaque consommateur à imaginer un workaround si HTTPS est important pour lui.

Choix : Alternative A. Coût équivalent en LOC à l'alternative B mais zéro impact non-consommateur.

Testing plan

Unit

Dans crates/rustwarden-fediverse/src/services/remote_personal_access_token_service.rs#tests :

  • Happy paths (5 méthodes × 1 cas = 5 tests).
  • Cas d'erreur :
    • create : remote_identity_id inexistant → NotFound.
    • create : name vide ou > 64 chars → Validation.
    • create : remote_identity disabled_at IS NOT NULL → Validation.
    • verify : token mal formé → None.
    • verify : token révoqué → None.
    • verify : token expiré → None.
    • verify : remote_identity disabled entre création et verify → None.
    • revoke × 2 sur même id → idempotent.
    • revoke_all_for_remote_identity : retourne nombre exact d'updates.
  • Constant-time hash check : verify avec un token dont le SHA-256 partage les N premiers bytes d'un hash existant doit échouer (pas de timing leak).

Doctest

Exemple create + verify dans le doc comment de struct. Marqué no_run (touche la DB).

Integration

Test bout en bout avec SQLite in-memory : create → verify → revoke → verify (None) → revoke_all → list (empty).

Compat

cargo test -p rustwarden-core reste vert (rien ne change côté core).

Bench

Non requis (lookup indexé sur token_hash, déjà optimal).

Fuzz

Optionnel : fuzz le parsing du préfixe rwfed_pat_ dans verify (10 min cargo fuzz). Robustesse pure, pas d'enjeu sécurité critique (le hash final est constant-time).

Migration guide

À copier dans le CHANGELOG.md de rustwarden-fediverse au merge :

Added (0.X+1.0)

  • RemotePersonalAccessTokenService<S> — émettre, vérifier, lister et révoquer des PAT locaux pour des identités ActivityPub remote.
  • Nouveau modèle models::fediverse_personal_access_token::Model.
  • Migration m20260516_000003_remote_pat ajoutée à FediverseMigrator.
  • Préfixe de token rwfed_pat_ (43 chars base32 après le préfixe), distinct du rw_pat_ du framework.

Migration depuis l'absence

Aucune. Première livraison de l'API.

Steps pour gitrust spécifiquement (post-merge)

  1. Bump rustwarden-fediverse dans Cargo.toml.
  2. Étendre /settings/tokens (handler gitrust) pour discriminer Identity::Local vs Identity::Remote et appeler le bon service.
  3. Étendre les middlewares d'auth HTTPS (push/clone, API) pour discriminer le préfixe du token entrant et appeler le bon verify.
  4. Ajouter un test E2E : alice (remote, Developer de B/widgets) génère un PAT scope "repo:write", l'utilise pour git push https://alice:TOKEN@gitrust-b/widgets.git, push accepté.

Open questions

  • Format du préfixe : rwfed_pat_ vs un préfixe purement consumer-defined ? Argument pour : auto-routing trivial côté consommateur sans flag DB. Argument contre : impose un format au framework qui devrait rester agnostique. Recommandation : laisser le framework choisir le préfixe canonique mais documenter qu'un consommateur peut overrider via une méthode statique helper.
  • Audit log : émettre on_audit_event à create/revoke ? Probablement oui (parité avec PersonalTokenService).
  • Listing UI cross-instance : un admin local doit-il pouvoir lister TOUS les PAT remote actifs sur son instance (pour incident response) ? Si oui : ajouter list_all_active(db, limit, offset). Hors scope V1, à empiler.
  • Cascade Delete{Actor} : ON DELETE CASCADE sur la FK fait disparaître les rows. Faut-il à la place soft-delete (revoked_at = NOW()) pour conserver une trace dans les audit logs ? À discuter.

Score sur la grille du processus §4

CritèreNoteJustification
Généralité5Pair fonctionnel de la RFC 0001, tout consommateur AP
Stabilité API5Additif pur
Performance5Lookup indexé identique au PAT framework
Sécurité5Hash SHA-256 + constant-time + cascade Delete{Actor} → posture égale au framework
Complexité impl4< 200 LOC service (réutilise les patterns du PersonalTokenService)
Maintenance5Zéro nouvelle dépendance
Documentation5Rustdoc + doctest + entrée CHANGELOG + guide migration
Total34/35≥ 30 = accélération possible

Tracking issue

À ouvrir sur le repo rustwarden-core avec labels rfc + area:fediverse. Mentionner que gitrust est consommateur prêt à adopter dès livraison. Référencer la RFC 0001 (RemoteResourceShareService) pour cohérence des paires fonctionnelles.


Historique :

  • 2026-05-15 : Draft initial — gap identifié lors de la planification de l'autonomie des users remote côté gitrust (auth HTTPS git via PAT).