RFC 0030 — RemotePersonalAccessTokenService dans rustwarden-fediverse
- Auteur : EBO (gitrust)
- Date : 2026-05-15
- Statut : Accepted (34/35)
- Origine : RFC 0002 du tracking gitrust (docs/rfcs/gitrust/0002-fediverse-remote-personal-access-tokens.md)
- Apps consommatrices concernées : gitrust (immédiat). Tout futur consommateur qui active
experimental-fediverseet veut permettre à une identité remote d'émettre des PAT locaux pour des opérations git/API. - Crate cible :
rustwarden-fediverse(PASrustwarden-core) - Dépendance : indépendante de la RFC 0029 (peut être mergée avant ou après).
- Évaluation détaillée : docs/plans/2026-05-15-rfcs-gitrust-evaluation.md
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 :
- 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. - Stabilité API : le trait
TokenScopedu framework est exposé aux consommateurs. Ajouter un paramètreowner: Principalcasserait toutes les implémentations downstream. - Cycle de vie :
experimental-fediversepermet l'itération libre tant qu'on n'a pas atteint la promotion stable. - 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émapersonal_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 derw_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 CASCADEsur la FKremote_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_idau lieu deuser_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 durw_pat_framework → pas de collision possible. -
Trait
TokenScopedu 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 unPrincipalau lieu d'unUuid→ 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_idinexistant →NotFound.create: name vide ou > 64 chars →Validation.create: remote_identitydisabled_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_patajoutée àFediverseMigrator. - Préfixe de token
rwfed_pat_(43 chars base32 après le préfixe), distinct durw_pat_du framework.
Migration depuis l'absence
Aucune. Première livraison de l'API.
Steps pour gitrust spécifiquement (post-merge)
- Bump
rustwarden-fediversedansCargo.toml. - Étendre
/settings/tokens(handler gitrust) pour discriminerIdentity::LocalvsIdentity::Remoteet appeler le bon service. - Étendre les middlewares d'auth HTTPS (push/clone, API) pour discriminer le préfixe du token entrant et appeler le bon
verify. - 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é avecPersonalTokenService). -
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 CASCADEsur 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ère | Note | Justification |
|---|---|---|
| Généralité | 5 | Pair fonctionnel de la RFC 0001, tout consommateur AP |
| Stabilité API | 5 | Additif pur |
| Performance | 5 | Lookup indexé identique au PAT framework |
| Sécurité | 5 | Hash SHA-256 + constant-time + cascade Delete{Actor} → posture égale au framework |
| Complexité impl | 4 | < 200 LOC service (réutilise les patterns du PersonalTokenService) |
| Maintenance | 5 | Zéro nouvelle dépendance |
| Documentation | 5 | Rustdoc + doctest + entrée CHANGELOG + guide migration |
| Total | 34/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).
GitRust