Rustwarden Services — Reference pour les projets consommateurs
Audience : developpeurs de projets qui consomment
rustwarden-core(PasterMan, GitRust, etc.)Ce document decrit les services exposes par le framework, leurs methodes publiques, les patterns d'utilisation et les invariants de securite qu'ils garantissent.
⚠️ Breaking (CSRF structurel — 2026-06) — action requise lors de la mise à jour. Le framework câble désormais lui-même la protection CSRF sur un routeur « browser » dédié, et l'API du builder sépare deux familles de routes :
merge_browser_routes(..)/with_ui(..)→ routeur browser, CSRF appliqué (cookie de session) ;merge_machine_routes(..)→ routeur machine, jamais de CSRF (API JSON Bearer, webhooks, inbox ActivityPub).
merge_routes(..)vaut maintenantmerge_browser_routes(CSRF par défaut, fail-safe). Tout consommateur qui montait une API JSON / un webhook viamerge_routesdoit le migrer versmerge_machine_routes, sinon ces routes renverront403faute de token. Le routeur UI est partitionné :rustwarden_ui::default_routes()est déprécié au profit debrowser_routes()(SSR) +api_routes()(/api/v1/*). Détails et table de migration en §18.
⚠️ Security 1.1.0 (RFC 0036-0041) — propagé par
update-project. Minor de sécurité : 3 API ajoutées (Username§3,SecurityHeadersConfig§18,EmailChangeService§3), 4 changements de comportement (§3create_user/update_profile, §4authenticate_user, §19 TOTP), 2 migrations SeaORM à jouer (m20260918_000010_totp_last_used_step,m20260919_000011_pending_email_changes), 5 routes ajoutées (§3). Fenêtre d'adoption 14 jours.Actions consommateur : (1) jouer la migration avant de déployer le binaire (
cargo run --bin migrate) — le modèle SeaORMuser_totpdéclarelast_used_step, donc sur un schéma pré-migration toute requête suruser_totpéchoue, dontTotpService::is_totp_enabledappelée à chaque login : un réplica 1.1.0 sur un schéma pré-migration fait échouer tous les logins en 500, pas seulement ceux des comptes 2FA ; (2) vérifier que les usernames générés par votre app passentUsername::parse([a-z0-9-], 3–39, pas de majuscule) ; (3)update_profilen'écrit plususers.email: tout appel passant une nouvelle adresse renvoieAppError::Validation— basculer surEmailChangeService::request_change(utilisateur) ouadmin_force_change(opérateur, en remplacement dePUT /admin/users/{id}pour l'adresse), et vérifier que SMTP + le workeremail_queuetournent avant le bump (§3) ; (4) le hookauth.login_blockedn'est plus émis par le blocage e-mail (la ligneaudit_logsl'est toujours) ; (5) retirer les workarounds : newtypeUsernamelocal, overlaySetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …), patch local re-vérifiant l'e-mail, appel applicatif àPasswordChangeService::invalidate_on_email_change. Détail : MIGRATION_GUIDE.md § 2026-09-18 — RFC 0036-0041 (1.1.0) et CHANGELOG[1.1.0].
⚠️ Security 1.0.1 — propagé par
update-project. Patch interne (pas de nouvelle APIpub). Fenêtre d'adoption 14 jours. Actions consommateur : (1) si vous servez le routeur vous-mêmes,into_make_service_with_connect_info::<SocketAddr>()—RustwardenApp::run()le fait déjà ; (2)JWT_SECRETégal à un placeholder connu (dontREMPLACER_PAR_UNE_CLE_SECRETE_LONGUE) refuse le boot ; (3)database/.envdoit définirPOSTGRES_PASSWORD(plus de fallback compose) et PostgreSQL n'écoute que127.0.0.1:5432; (4) retirer les workarounds dupliqués (garde JWT maison, rate-limit IP headers-only,RateLimitService::new()par requête). Détail : MIGRATION_GUIDE.md § 2026-09-18 et CHANGELOG[1.0.1].
Table des matieres
- Architecture generale
- Acces aux services
- Gestion des utilisateurs
- Authentification
- RBAC — Roles et Permissions
- Mots de passe
- Tokens et sessions
- Ressources et partage
- Teams (groupes d'utilisateurs)
- Configuration applicative
- Preferences utilisateur
- Chiffrement
- OAuth
- Rate limiting
- Notifications temps-reel et SSE
- Hooks lifecycle et audit
- Middleware (CSRF, auth, rate limit)
- Utilitaires
- Patterns communs
- Fédération ActivityPub (features
fediverse*) - Binaire
screen-select— plan des écrans UI - Mesure d'audience
1. Architecture generale
Tous les services suivent un pattern uniforme :
Handler (axum) → Service (logique metier) → SeaORM (base de donnees)
Conventions :
- Les services sont des structs sans etat avec des methodes
async fnstatiques, ou des structs legeres avec un etat minimal (rate limiter, config). - Le premier parametre est toujours
db: &DatabaseConnection. - Le type de retour est toujours
Result<T, AppError>. - Aucun
unwrap()/expect()en production — toute erreur remonte via?. - Aucun SQL brut — tout passe par SeaORM.
Services exportes dans le prelude (use rustwarden_core::prelude::*) :
- Users / RBAC :
UserService,RoleService,PermissionService - Ressources :
ResourceService,Principal - Teams :
TeamService,TeamRole,TeamSlug - Tokens API :
PersonalTokenService,TokenScope - Auth :
AuthUser,Claims,JwtService - App :
AppError,RustwardenHooks,RustwardenApp
Les autres services sont accessibles via leur chemin complet :
use rustwarden_core::services::refresh_token_service::RefreshTokenService; use rustwarden_core::services::app_settings_service::AppSettingsService; use rustwarden_core::services::rate_limit_service::RateLimitService; use rustwarden_core::notifications::{NotificationBus, InMemoryBus, sse::sse_response}; use rustwarden_core::middleware::csrf::{csrf_protection, csrf_protection_with_config, CsrfProtectionConfig}; // etc.
2. Acces aux services
Depuis un projet consommateur, les services sont utilises de deux facons :
Dans les handlers Axum (via State)
use rustwarden_core::prelude::*; use axum::extract::State; use sea_orm::DatabaseConnection; async fn my_handler( State(db): State<DatabaseConnection>, user: AuthUser, // extrait automatiquement du JWT ) -> Result<impl IntoResponse, AppError> { let roles = UserService::get_user_roles(&db, user.user_id).await?; // ... }
En dehors des handlers (migrations, seeding, taches)
let app = RustwardenApp::builder().from_env().build().await?; let db = app.database(); // Migrations metier my_app::migrations::run(&db).await?; // Seeding let roles = RoleService::list_all_roles(&db).await?;
3. Gestion des utilisateurs
UserService
Module : rustwarden_core::services::user_service
Export : prelude
Gestion CRUD des utilisateurs et de leurs roles.
Methodes
| Methode | Signature | Description |
|---|---|---|
create_user | (db, username: String, email: String, password_hash: String, must_change_password: bool) → Result<user::Model> | Cree un utilisateur. Le mot de passe doit etre pre-hashe via PasswordService::hash_password. 1.1.0 : valide username via Username::parse — porte de validation unique (RFC 0036). email_verified est toujours initialise a false. |
get_user_by_id | (db, user_id: Uuid) → Result<Option<user::Model>> | Recherche par ID. |
get_user_by_username | (db, username: &str) → Result<Option<user::Model>> | Recherche par nom d'utilisateur. |
get_user_by_email | (db, email: &str) → Result<Option<user::Model>> | Recherche par email. |
list_all_users | (db) → Result<Vec<user::Model>> | Liste tous les utilisateurs. |
get_all_admin_users | (db) → Result<Vec<user::Model>> | Liste les utilisateurs avec le role admin. |
get_user_roles | (db, user_id: Uuid) → Result<Vec<String>> | Retourne les noms des roles d'un utilisateur (ex: ["admin", "user"]). |
assign_role_to_user | (db, user_id: Uuid, role_id: Uuid) → Result<user_role::Model> | Assigne un role a un utilisateur. |
unassign_role_from_user | (db, user_id: Uuid, role_id: Uuid) → Result<()> | Retire un role a un utilisateur. Empeche de retirer le role admin au dernier administrateur. |
update_profile | (db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model> | Met a jour le profil. Valide l'unicite du username. 1.1.0 : Username::parse sur un vrai rename uniquement (RFC 0036) ; n'ecrit plus jamais users.email — un vrai changement d'adresse renvoie AppError::Validation vers EmailChangeService (RFC 0041), un echo de l'adresse courante reste un no-op. |
change_password | (db, user_id: Uuid, old_password: &str, new_password: &str, current_token_id: Option<Uuid>) → Result<(Uuid, String)> | Initie un changement de mot de passe avec confirmation email (cree une demande pendante). Retourne (request_id, plain_token). |
change_password_direct | (db, user_id: Uuid, old_password: &str, new_password: &str) → Result<()> | Change le mot de passe directement (sans confirmation email). Verifie l'ancien mot de passe, valide la complexite, revoque les sessions (SEC-M7). |
admin_reset_password | (db, user_id: Uuid, new_password: &str) → Result<()> | Reset admin : hash et applique le nouveau mot de passe. Force must_change_password = true. Revoque les sessions (SEC-M7). |
delete_user | (db, user_id: Uuid, hooks: Option<&dyn RustwardenHooks>) → Result<()> | Supprime un utilisateur. Appelle le hook on_user_deleted, revoque les tokens, cascade DB. Empeche de supprimer le dernier admin. |
update_password_hash | (db, user_id: Uuid, password_hash: &str) → Result<()> | Met a jour directement le hash — usage interne (PasswordChangeService). |
Modele user::Model
| Champ | Type | Description |
|---|---|---|
id | Uuid | Identifiant unique |
username | String | Nom d'utilisateur unique |
email | String | Email unique |
password_hash | String | Hash bcrypt |
email_verified | bool | Email verifie |
must_change_password | bool | Forcer le changement au prochain login |
last_login_at | Option<DateTime> | Derniere connexion |
last_login_ip | Option<String> | IP de derniere connexion |
created_at | DateTime | Date de creation |
updated_at | DateTime | Derniere mise a jour |
Regles de nommage (RFC 0036, depuis 1.1.0)
create_user (porte unique) et update_profile (sur rename uniquement)
refusent tout username hors charset [a-z0-9-]{3,39} — regles completes et
exemples dans Username (value object) ci-dessous.
POST /api/v1/auth/register applique le meme parse en defense en profondeur
(RegisterRequest.username : max passe de 64 a 39).
Aucun CHECK SQL, aucune migration, aucun rewrite : les comptes existants
hors charset continuent de se connecter ; seules les ecritures sont
controlees. Un client qui renvoie le username courant (formulaire de profil
postant tous les champs) est traite en no-op — le grandfathering est preserve.
Changement d'adresse e-mail (RFC 0041, depuis 1.1.0)
update_profile n'ecrit plus jamais users.email :
| Cas | Comportement |
|---|---|
Nouvelle adresse (comparaison exacte apres trim) | AppError::Validation — le message renvoie explicitement vers EmailChangeService::request_change / POST /api/v1/auth/email-change. Rien n'est ecrit, y compris le username passe dans le meme appel |
| Echo de l'adresse courante | No-op complet : le champ est ignore, le reste du profil est mis a jour normalement |
| Rename username seul | Inchange (RFC 0036) |
| Chemin admin | Identique : PUT /admin/users/:id (rustwarden-ui) passe par update_profile et ne change donc plus l'adresse. Le recours operateur est admin_force_change |
Le changement d'adresse reel est un flux en deux temps decrit ci-dessous. Tant que le lien envoye a la nouvelle boite n'est pas suivi, rien ne change en base : l'adresse courante reste celle du compte et reste verifiee.
EmailChangeService
Module : rustwarden_core::services::email_change_service
RFC : 0041 — depuis 1.1.0
Migration : m20260919_000011_pending_email_changes
Changement d'adresse e-mail en deux temps, avec preuve de possession de la nouvelle boite et droit d'alerte de l'ancienne.
| Methode | Signature | Description |
|---|---|---|
new | (config: EmailConfig) → Self | Construit le service. |
with_i18n | (self, i18n: Arc<I18nService>) → Self | Traduit sujets et corps des mails ; sans elle, libelles anglais embarques. |
request_change | (&self, db, user_id: Uuid, new_email: &str, current_password: &str) → Result<()> | Re-authentifie avec le mot de passe courant, valide format et unicite, consomme toute demande pendante du compte, ecrit la ligne d'attente et met deux mails en file (confirmation vers la nouvelle adresse, avis + lien de signalement vers l'ancienne). users.email et email_verified ne bougent pas. |
confirm_change | (&self, db, token: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<Uuid> | Ecrit la nouvelle adresse deja verifiee, revoque tous les refresh tokens du compte, invalide les demandes de changement de mot de passe pendantes, trace audit_logs email_changed. |
report_suspicious | (&self, db, report_token: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<Uuid> | Marque la demande signalee, trace en audit_logs (WARN) et alerte tous les administrateurs. Ne bloque pas le changement. |
admin_force_change | (&self, db, actor_id: Uuid, user_id: Uuid, new_email: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<()> | Recours operateur : exige le role admin (sinon AppError::Forbidden), ecrit l'adresse sans confirmation, revoque les sessions de la cible, notifie les deux boites, trace email_changed_by_admin (WARN). on_email_verified n'est pas emis. |
cleanup_expired | (db) → Result<u64> | Supprime les lignes expirees depuis plus de 30 jours (consommees et signalees comprises). |
inspect_confirmation_token | (db, token: &str) → Result<EmailChangeLinkState> | Lecture sans effet de bord de l'etat d'un lien de confirmation. |
inspect_report_token | (db, report_token: &str) → Result<EmailChangeLinkState> | Idem pour un lien de signalement. |
EmailChangeLinkState : Usable, Unknown, Expired, Used.
Duree de validite : email_validation_expiration_hours — le reglage deja
utilise par la validation d'adresse, pas de nouvelle variable d'environnement.
Limiteur : 3 demandes par heure et par utilisateur (email_change:{user_id},
budget distinct de email_verify et pwd_reset), plus le budget IP existant sur
POST /api/v1/auth/email-change. Depassement → AppError::TooManyRequests.
Fail-loud : contrairement aux notifications best-effort du reste du
framework, request_change echoue si la mise en file des mails echoue —
mieux vaut pas de demande qu'une demande dont le lien n'arrivera jamais. Rien
n'est ecrit sur le compte dans ce cas.
Masquage : adresses masquees (jo***@ex***) dans les logs, audit_logs,
payloads de hooks et corps de reponse de la demande.
Routes (canal machine)
Les liens envoyes par e-mail ouvrent une page interstitielle : le GET
affiche un formulaire, c'est le POST du bouton qui agit. Aucun GET n'a
d'effet de bord — une passerelle anti-phishing qui precharge les liens ne peut
donc pas confirmer un changement d'adresse a la place de l'utilisateur.
| Methode | Chemin | Auth | Effet |
|---|---|---|---|
| POST | /api/v1/auth/email-change | Bearer + mot de passe courant dans le corps | ouvre la demande |
| GET | /api/v1/auth/email-change/confirm/{token} | public (lien de mail) | rend la page, n'ecrit rien |
| POST | /api/v1/auth/email-change/confirm/{token} | public (bouton de la page) | applique le changement |
| GET | /api/v1/auth/email-change/report/{token} | public (lien de mail) | rend la page, n'ecrit rien |
| POST | /api/v1/auth/email-change/report/{token} | public (bouton de la page) | enregistre le signalement |
Montees par auth_api_routes(), donc par merge_machine_routes — jamais
derriere la couche CSRF, sans quoi la page ouverte depuis une boite mail serait
inutilisable. Les pages sont du HTML minimal autoportant produit par le core :
ni Askama, ni rustwarden-ui, ni JavaScript, ni ressource externe. Pour votre
propre presentation, montez vos routes GET et postez vers les routes POST
du core.
Purge des demandes expirees
PendingEmailChangeCleanupProcessor (prelude) purge les lignes expirees depuis
plus de 30 jours. Comme PersonalTokenCleanupProcessor et
PasswordChangeCleanupProcessor, app.rs ne le demarre pas :
use rustwarden_core::prelude::PendingEmailChangeCleanupProcessor; PendingEmailChangeCleanupProcessor::new(db.clone()).start();
Retention en constante de code, aucune variable d'environnement. Sans ce demarrage, une purge opportuniste limitee a l'utilisateur courant s'execute a chaque nouvelle demande.
⚠️ Ce que la confirmation deconnecte :
confirm_changerevoque tous les refresh tokens du compte. L'utilisateur qui confirme depuis sa nouvelle boite est deconnecte partout et doit se reconnecter avec la nouvelle adresse. Dites-le dans l'ecran qui ouvre la demande.⚠️ Le signalement n'annule rien : le lien envoye a l'ancienne adresse alerte les administrateurs et trace en
audit_logs, il ne bloque pas le changement. C'est un choix explicite — un attaquant qui controle l'ancienne boite ne doit pas pouvoir bloquer une reprise de controle legitime.⚠️ Recours perdu : un utilisateur qui a perdu a la fois son mot de passe et l'acces a son adresse courante ne se debloque plus seul ; c'est un
admin_force_changeapres verification d'identite hors bande. La faute de frappe a l'inscription releve du meme recours :registerecrit l'adresse saisie sans double verification, hors perimetre de la RFC 0041.
Username (value object)
Module : rustwarden_core::types::username
Export : prelude
RFC : 0036 — depuis 1.1.0
Newtype valide pour un identifiant de connexion local, calque sur TeamSlug.
| Methode | Signature | Description |
|---|---|---|
parse | (raw: &str) → Result<Username, AppError> | Valide charset, longueur, tirets et noms reserves. AppError::Validation (400) sinon. |
as_str | (&self) → &str | Vue empruntee. |
into_inner | (self) → String | Consomme le newtype (a passer a create_user). |
Derive Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize (#[serde(transparent)]) et implemente Display.
Regles appliquees par parse :
| Regle | Detail |
|---|---|
| Longueur | 3 a 39 caracteres, mesures apres trim() |
| Charset | [a-z0-9-] uniquement |
| Majuscules | Rejetees, jamais repliees — Alice echoue, il ne devient pas alice |
| Tirets | Ni en tete ni en queue, pas de -- consecutifs |
| Noms reserves | types::team_slug::RESERVED + EXTRA_RESERVED (me, explore) |
use rustwarden_core::prelude::*; let username = Username::parse("squad-42")?; // Ok assert!(Username::parse("Alice").is_err()); // majuscule → refus, pas de fold assert!(Username::parse("john_doe").is_err()); // underscore hors charset assert!(Username::parse("me").is_err()); // nom reserve UserService::create_user(&db, username.into_inner(), email, password_hash, true).await?;
Chemin bootstrap : Username::parse_allow_reserved et
UserService::create_user_allow_reserved sont pub(crate) et reserves a
ensure_default_admin (pour que ADMIN_USERNAME=admin continue de booter).
Ils appliquent toujours charset et longueur — un ADMIN_USERNAME avec une
majuscule ou un _ fait echouer le boot sur le chemin de creation de l'admin.
Usernames OAuth : OAuthService::generate_unique_username produit deja des
candidats conformes (minuscules [a-z0-9], base tronquee a 30 caracteres,
suffixes -NNN / -user / -<hex8>).
A retirer cote consommateur : tout newtype Username / validate_username()
local — grep -rni "struct Username\|validate_username" src/.
4. Authentification
AuthService
Module : rustwarden_core::services::auth_service
Verification des credentials avec protection contre le timing attack (SEC-C1).
| Methode | Signature | Description |
|---|---|---|
Le parametre identifier accepte indifferemment un username ou un email (lookup par email s'il contient @). |
| Methode | Signature | Description |
|---|---|---|
verify_credentials | (db, identifier: &str, password: &str) → Result<user::Model> | Primitive mot de passe seul. Utilise un dummy hash si l'utilisateur n'existe pas pour eviter les timing attacks. N'applique pas la politique e-mail verifie — a reserver aux re-authentifications (re-prompt avant setup TOTP). |
authenticate_user | (db, identifier: &str, password: &str) → Result<user::Model> | Porte d'authentification complete. Appelle verify_credentials, logue si must_change_password, puis applique la politique e-mail verifie (RFC 0040, depuis 1.1.0). |
Securite :
DUMMY_HASH: hash bcrypt pre-calcule utilise quand le username n'existe pas. Le temps de reponse est identique qu'il y ait ou non un utilisateur correspondant (SEC-C1).mask_username(): masque les usernames dans les logs (affiche uniquement le premier tiers des caracteres).
Politique « e-mail verifie » (RFC 0040, depuis 1.1.0)
Le controle vivait dans le handler POST /api/v1/auth/login ; il a ete
deplace dans authenticate_user, pour qu'une app qui appelle le service
directement (SSR maison, handler custom) ne puisse plus le contourner.
| Aspect | Valeur |
|---|---|
| Reglage | validation_email_required (alias legacy validation_email_requise), lu via AppSettingsService::get_bool_with_fallback |
| Defaut | true — fail-closed : une erreur de lookup vaut « verification requise » |
| Erreur | AppError::Validation → HTTP 400, message francais destine a l'utilisateur final (inchange depuis 1.0.1) |
| Ordre | Mot de passe verifie d'abord → pas d'oracle « compte existant mais non verifie » |
| Audit | Ligne audit_logs ecrite (action = "login_blocked") via AuthLoggingService::log_blocked_login |
| Hook | ⚠️ on_audit_event / auth.login_blocked n'est plus emis sur ce chemin (hooks = None, decision v1). Une alerte branchee sur ce hook doit basculer sur audit_logs. |
use rustwarden_core::services::auth_service::AuthService; use rustwarden_core::error::AppError; match AuthService::authenticate_user(&db, &form.username, &form.password).await { Ok(user) => issue_session(user).await, // Message deja redige pour l'utilisateur : proposer « renvoyer l'email ». Err(AppError::Validation(msg)) => render_login_error(&msg, /* show_resend = */ true), Err(AppError::InvalidCredentials) => render_login_error("Identifiants invalides.", false), Err(e) => Err(e), }
A retirer cote consommateur : tout patch local re-verifiant email_verified
apres authenticate_user (double controle = double message d'erreur).
grep -rn "authenticate_user\|email_verified" src/.
Le middleware Tower auth_service::AuthService (préfixes protégés) est fail-closed depuis 1.0.1 : erreur DB à la blacklist JWT → refus ; utilisateur introuvable → non authentifié ; erreur DB du statut TOTP quand totp_force_enabled → 503. Un JWT encore valide pour un compte supprimé ne traverse plus.
AuthUser (extracteur)
Module : rustwarden_core::auth::extractors
Export : prelude
Extracteur Axum qui valide le JWT depuis le cookie jwt_token ou le header Authorization: Bearer.
async fn handler(user: AuthUser) -> impl IntoResponse { // user.user_id, user.username, user.roles, user.permissions }
| Methode | Description |
|---|---|
has_role(role: &str) → bool | Verifie si l'utilisateur a un role. |
has_any_role(roles: &[&str]) → bool | Verifie si l'utilisateur a l'un des roles. |
has_all_roles(roles: &[&str]) → bool | Verifie si l'utilisateur a tous les roles. |
has_permission(permission: &str) → bool | Verifie une permission. Les admin ont implicitement toutes les permissions. |
Rejection : retourne 401 Unauthorized si le JWT est absent, expire, invalide, ou blackliste.
JwtService
Module : rustwarden_core::auth::jwt
Export : prelude
| Methode | Signature | Description |
|---|---|---|
generate_token | (config: &AuthConfig, user_id: Uuid, username: &str, roles: Vec<String>, permissions: Vec<String>) → Result<String> | Genere un JWT signe. |
validate_token | (config: &AuthConfig, token: &str) → Result<Claims> | Valide et decode un JWT. |
extract_token_from_header | (header: &str) → Result<&str> | Extrait le token d'un header Authorization: Bearer <token>. |
Claims (contenu du JWT)
| Champ | Type | Description |
|---|---|---|
sub | Uuid | User ID |
username | String | Nom d'utilisateur |
roles | Vec<String> | Roles de l'utilisateur |
permissions | Vec<String> | Permissions resolues |
exp | i64 | Timestamp d'expiration |
iat | i64 | Timestamp d'emission |
iss | String | Issuer |
identity_kind | IdentityKind | Local (user en DB) ou Remote (identité fédérée). Sérialisé "local" / "remote". RFC 0007. |
remote_id | Option<Uuid> | Si identity_kind == Remote : ID de la row remote_identities. Sinon None. |
Enum IdentityKind (module rustwarden_core::identity, exporté via prelude) :
pub enum IdentityKind { Local, Remote }
Permet au middleware d'auth de différencier une session humaine locale d'une session AP-native (un user d'une instance distante connecté via le flow /fediverse/auth/*). Voir aussi Identity — abstraction unifiant les deux types pour les services qui n'ont pas besoin de la distinction.
5. RBAC — Roles et Permissions
RoleService
Module : rustwarden_core::services::role_service
Export : prelude
| Methode | Signature | Description |
|---|---|---|
create_role | (db, name: String, description: Option<String>) → Result<role::Model> | Cree un role. |
get_role_by_name | (db, name: &str) → Result<Option<role::Model>> | Recherche par nom. |
get_role_by_id | (db, id: Uuid) → Result<Option<role::Model>> | Recherche par ID. |
list_all_roles | (db) → Result<Vec<role::Model>> | Liste tous les roles. |
update_role | (db, id: Uuid, name: Option<String>, description: Option<String>) → Result<role::Model> | Met a jour un role. |
delete_role | (db, id: Uuid) → Result<()> | Supprime un role. Roles proteges : admin et user ne peuvent pas etre supprimes. |
count_users_for_role | (db, role_id: Uuid) → Result<u64> | Compte les utilisateurs ayant ce role. |
initialize_default_roles | (db) → Result<(role::Model, role::Model, role::Model)> | Cree les roles par defaut (admin, user, app_manager) si absents. Idempotent. |
Roles par defaut :
| Role | Description |
|---|---|
admin | Administrateur — acces complet, toutes les permissions implicites |
user | Utilisateur standard |
app_manager | Gestionnaire d'application |
PermissionService
Module : rustwarden_core::services::permission_service
Export : prelude
| Methode | Signature | Description |
|---|---|---|
list_all | (db) → Result<Vec<permission::Model>> | Liste toutes les permissions. |
get_by_id | (db, id: Uuid) → Result<Option<permission::Model>> | Recherche par ID. |
get_by_name | (db, name: &str) → Result<Option<permission::Model>> | Recherche par nom. |
create | (db, name: String, description: Option<String>, category: String) → Result<permission::Model> | Cree une permission. |
delete | (db, id: Uuid) → Result<()> | Supprime une permission. |
get_permissions_for_user | (db, user_id: Uuid) → Result<Vec<String>> | Retourne l'union des permissions de tous les roles de l'utilisateur. |
get_permissions_for_role | (db, role_id: Uuid) → Result<Vec<permission::Model>> | Permissions assignees a un role. |
set_role_permissions | (db, role_id: Uuid, permission_ids: Vec<Uuid>) → Result<()> | Remplace atomiquement les permissions d'un role. |
initialize_default_permissions | (db) → Result<()> | Seed les 12 permissions par defaut. Idempotent. |
Permissions par defaut (4 categories × 3 niveaux) :
| Categorie | Permissions |
|---|---|
users | users.view, users.manage, users.delete |
roles | roles.view, roles.manage, roles.delete |
settings | settings.view, settings.manage, settings.delete |
audit | audit.view, audit.manage, audit.delete |
Logique d'acces : le role admin bypass le systeme de permissions — AuthUser::has_permission() retourne toujours true pour un admin.
6. Mots de passe
PasswordService
Module : rustwarden_core::services::password_service
Operations de hashing et validation. Les methodes hash_password et verify_password sont
async et offloadent le travail CPU-intensif de bcrypt sur tokio::task::spawn_blocking,
ce qui evite de bloquer le runtime Tokio.
| Methode | Signature | Description |
|---|---|---|
hash_password | async (password: &str) → Result<String> | Hash bcrypt via spawn_blocking. Cout configurable via BCRYPT_COST (defaut: 12). |
verify_password | async (password: &str, hash: &str) → Result<bool> | Verification bcrypt via spawn_blocking. |
generate_secure_password | () → String | Genere un mot de passe de 20 caracteres avec mix garanti (majuscules, minuscules, chiffres, speciaux). |
generate_default_password | () → String | Alias de generate_secure_password. |
PasswordChangeService
Module : rustwarden_core::services::password_change_service
Flux de changement de mot de passe en deux etapes (demande + confirmation par email).
Rate limiting delegue a RateLimitService via check_sliding_window("pwd_change:{user_id}", 3, 1h) — retourne AppError::TooManyRequests { retry_after_secs } (HTTP 429 + header Retry-After) au-dela.
| Methode | Signature | Description |
|---|---|---|
create_pending_request | (&self, db, user_id: Uuid, new_password: &str) → Result<(Uuid, String)> | Cree une demande pendante. Rate limited. Retourne (request_id, plain_token). |
confirm_request | (&self, db, token: &str) → Result<Uuid> | Confirme le changement via le token email. Applique le nouveau hash et revoque tous les refresh tokens (SEC-M7). |
find_by_token | (&self, db, token: &str) → Result<password_change_request::Model> | Trouve une demande par token (verifie expiration et statut). |
find_pending_by_user_id | (&self, db, user_id: Uuid) → Result<Option<Model>> | Trouve la demande pendante d'un utilisateur. |
cancel_pending_request | (&self, db, user_id: Uuid) → Result<()> | Annule la demande pendante. |
invalidate_on_email_change | (&self, db, user_id: Uuid) → Result<u64> | Invalide les demandes quand l'adresse change (le token a ete envoye a l'ancienne). 1.1.0 : appele par le core lui-meme dans EmailChangeService::confirm_change et admin_force_change — ne l'appelez plus depuis un handler de profil (RFC 0041). |
cleanup_expired_requests | (&self, db) → Result<u64> | Nettoie les demandes expirees ou confirmees. |
cleanup_failed_requests | (&self, db) → Result<u64> | Supprime les demandes avec 3+ tentatives echouees. |
PasswordResetService
Module : rustwarden_core::services::password_reset_service
Flux "Mot de passe oublie" (reset par email).
| Methode | Signature | Description |
|---|---|---|
request_reset | (&self, db, email: &str) → Result<bool> | Envoie un email de reset. Retourne false si l'utilisateur n'existe pas (pas d'erreur, pour la securite). Rate limited : check_sliding_window("pwd_reset:{email}", 3, 1h) → 429 + Retry-After. |
validate_token | (&self, db, token: &str) → Result<Uuid> | Valide un token de reset. Retourne le user_id. |
reset_password | (&self, db, token: &str, new_password: &str) → Result<Uuid> | Valide le token, applique validate_password_complexity avant hash / used_at (1.0.1), marque le token utilise et revoque tous les refresh tokens (SEC-M7). Un mot de passe trop faible ne consomme plus le token. |
PasswordResetService::new(email_config) : signature inchangee. Depuis 1.0.1 le rate limiter interne est un OnceLock partage (plus une nouvelle map par new()). Ne pas recreer le service a chaque requete pour « partager » le limiter — c'est deja le cas. RateLimitService est Clone (Arc) : un new() par requete dans un autre service reste un anti-pattern (chaque instance a sa propre map).
7. Tokens et sessions
RefreshTokenService
Module : rustwarden_core::services::refresh_token_service
Gestion des refresh tokens avec rotation et detection de reutilisation.
Constante : MAX_ACTIVE_TOKENS_PER_USER = 10 (SEC-M4)
| Methode | Signature | Description |
|---|---|---|
generate_token | () → String | Genere un token aleatoire de 64 caracteres alphanumeriques. |
create_refresh_token | (db, user_id: Uuid, expiration_days: i64) → Result<CreatedRefreshToken> | Cree un refresh token. Revoque les plus anciens si > 10 actifs (SEC-M4). |
validate_refresh_token | (db, token: &str) → Result<refresh_token::Model> | Valide un token (hash SHA-256, existence, non revoque, non expire, non reutilise). |
mark_as_replaced | (db, old_token_id: Uuid, new_token_id: Uuid) → Result<Model> | Marque un token comme remplace (rotation). |
revoke_all_tokens_for_user | (db, user_id: Uuid) → Result<u64> | Revoque tous les tokens actifs (logout, changement de mot de passe). |
get_active_tokens_for_user | (db, user_id: Uuid) → Result<Vec<Model>> | Liste les tokens non revoques et non expires. |
cleanup_expired_tokens | (db) → Result<u64> | Supprime les tokens expires (tache de maintenance). |
count_active_sessions | (db) → Result<u64> | Compte les sessions actives tous utilisateurs confondus. |
list_user_sessions | (db, user_id: Uuid) → Result<Vec<ActiveSession>> | Liste les sessions actives d'un utilisateur. |
revoke_session | (db, token_id: Uuid) → Result<()> | Revoque une session specifique. |
delete_session | (db, token_id: Uuid) → Result<()> | Supprime definitivement un refresh token. |
Type CreatedRefreshToken :
pub struct CreatedRefreshToken { pub model: refresh_token::Model, // Enregistrement en base (hash stocke) pub plain_token: String, // Token en clair pour le client }
Type ActiveSession :
pub struct ActiveSession { pub id: Uuid, pub device_info: Option<String>, pub ip_address: Option<String>, pub last_activity: DateTime<Utc>, pub created_at: DateTime<Utc>, pub is_current: bool, }
Securite :
- Les tokens sont stockes hashes en SHA-256 en base. Le token en clair n'est retourne qu'a la creation.
- La detection de reutilisation (
has_been_used()) declenche la revocation de tous les tokens de l'utilisateur (defense contre le vol de token). - La rotation est obligatoire : chaque usage genere un nouveau token.
JwtBlacklistService
Module : rustwarden_core::services::jwt_blacklist_service
Blacklist des JWT revoques (logout).
| Methode | Signature | Description |
|---|---|---|
add_to_blacklist | (db, jwt_token: &str, user_id: Uuid) → Result<()> | Ajoute un JWT a la blacklist (au logout). Extrait automatiquement la date d'expiration. |
is_blacklisted | (db, jwt_token: &str) → Result<bool> | Verifie si un JWT est blackliste. Appele par le middleware auth a chaque requete. |
cleanup_expired_tokens | (db) → Result<u64> | Supprime les entrees expirees (tache de fond automatique). |
hash_jwt_token | (token: &str) → String | Hash SHA-256 (utilitaire public). |
PersonalTokenService<S: TokenScope>
Module : rustwarden_core::services::personal_token_service
Export : prelude (PersonalTokenService, TokenScope)
Tokens API longue-duree (Personal Access Tokens) pour protocoles qui ne peuvent pas supporter la rotation JWT (git push HTTPS, webhooks, integrations tierces). Complete — ne remplace pas — RefreshTokenService.
Securite :
- Plaintext (32 octets random hex, 64 chars) retourne une seule fois a la creation, jamais persiste.
- Stockage uniquement en SHA-256 hex.
- Comparaison constant-time a la verification (
subtle::ConstantTimeEq). - Erreur uniforme
AppError::InvalidTokensur tout rejet (pas de leak).
Generique sur un trait TokenScope implemente par chaque app consommatrice :
use rustwarden_core::prelude::{PersonalTokenService, TokenScope}; #[derive(Clone, Debug)] enum RepoScope { Read, Write } impl TokenScope for RepoScope { fn as_str(&self) -> &str { match self { Self::Read => "read:repo", Self::Write => "write:repo" } } fn parse(s: &str) -> Option<Self> { match s { "read:repo" => Some(Self::Read), "write:repo" => Some(Self::Write), _ => None, } } }
| Methode | Signature | Description |
|---|---|---|
create | (db, user_id, name: &str, scopes: &[S], expires_at: Option<DateTime<Utc>>) → Result<(Model, String)> | Cree un PAT. Retourne (record, plaintext). expires_at=None = permanent. AppError::Validation si name vide. |
verify | (db, token_clear: &str) → Result<(Model, Vec<S>)> | Valide un PAT et retourne le record + scopes parses. Update last_used_at best-effort. Refuse revoked/expired avec AppError::InvalidToken. |
list_for_user | (db, user_id: Uuid) → Result<Vec<Model>> | Liste les PATs actifs (non revokes), tries desc par created_at. |
revoke | (db, token_id: Uuid, user_id: Uuid) → Result<()> | Revoque un PAT. Idempotent. AppError::Forbidden si le token n'appartient pas a user_id. |
cleanup_expired | (db) → Result<u64> | Hard-delete les tokens expired AND revoked. Tokens expired-seulement gardes pour audit. Appele par PersonalTokenCleanupProcessor. |
Modele personal_access_token::Model :
id: Uuid,user_id: Uuid,name: String,token_hash: String(64 hex chars)scopes: Vec<String>,expires_at: Option<DateTime<Utc>>,last_used_at: Option<DateTime<Utc>>revoked_at: Option<DateTime<Utc>>,created_at: DateTime<Utc>- Helpers :
is_expired(),is_revoked(),is_active()
Background task : PersonalTokenCleanupProcessor::<S>::new(db).start() — intervalle via env PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS (default 3600).
Pattern d'extension AuthUser cote consommateur (exemple gitrust pour git HTTPS) :
// Dans votre extractor custom : tenter JWT Bearer puis PAT Bearer if let Some(jwt) = extract_bearer_jwt(headers) { /* ... */ } if let Some(plain) = extract_bearer_pat(headers) { let (record, scopes) = PersonalTokenService::<RepoScope>::verify(&db, &plain).await?; // Construire un AuthUser derivé du token }
Integration consommateur : voir DEVELOPER.md §14 — Nettoyage des Personal Access Tokens pour l'exemple complet de definition du scope et de demarrage du
PersonalTokenCleanupProcessor<S>dansmain.rs.
8. Ressources et partage
ResourceService
Module : rustwarden_core::services::resource_service
Export : prelude
Systeme generique de registre de ressources et de partage avec controle d'acces.
Hierarchie des permissions : read < write < admin
Registre
| Methode | Signature | Description |
|---|---|---|
register | (db, resource_type: &str, resource_id: Uuid, owner_id: Uuid) → Result<resource::Model> | Enregistre une ressource dans le registre generique. |
find_by_type_and_id | (db, resource_type: &str, resource_id: Uuid) → Result<Option<Model>> | Recherche dans le registre. |
find_by_id | (db, id: Uuid) → Result<Option<Model>> | Recherche par ID interne du registre. |
owned_by_user | (db, owner_id: Uuid, resource_type: &str) → Result<Vec<Model>> | Ressources d'un type possedees par un utilisateur. |
set_public | (db, resource_type: &str, resource_id: Uuid, is_public: bool) → Result<Model> | Bascule la visibilite publique. |
unregister | (db, resource_type: &str, resource_id: Uuid) → Result<()> | Supprime du registre (cascade sur les partages). |
Partage
Les partages acceptent deux types de principals :
pub enum Principal { User(Uuid), Team(Uuid), }
Principal est exporte via le prelude. Un share row porte exactement un des deux identifiants (contrainte CHECK au niveau DB).
| Methode | Signature | Description |
|---|---|---|
share_with | (db, resource_type, resource_id, principal: Principal, permission_level: &str, shared_by_user_id) → Result<resource_share::Model> | API moderne. Partage avec un user ou une team. Update si deja partage (uniqueness par (resource_id, principal) via partial unique indexes). Refuse si principal == owner. |
share | (db, resource_type, resource_id, shared_with_user_id, permission_level, shared_by_user_id) → Result<resource_share::Model> | Legacy wrapper sur share_with(Principal::User(..)), conserve pour retrocompat. |
revoke_share | (db, share_id: Uuid) → Result<()> | Revoque un partage par ID. |
revoke_user_access | (db, resource_type, resource_id, user_id) → Result<()> | Revoque tous les partages user pour un couple (ressource, utilisateur). |
list_shares | (db, resource_type, resource_id) → Result<Vec<resource_share::Model>> | Liste tous les partages d'une ressource (user + team). |
Controle d'acces
| Methode | Signature | Description |
|---|---|---|
user_can_access | (db, user_id, resource_type, resource_id, required_level: &str) → Result<bool> | Acces au niveau requis. Verifie : proprietaire → public+read → partage direct (user). |
effective_permission | (db, user_id, resource_type, resource_id) → Result<Option<String>> | Legacy — ignore les teams. Conservee pour retrocompat. |
effective_permission_for_user | (db, user_id, resource_type, resource_id) → Result<Option<String>> | API moderne — calcule le max sur : ownership → partage user direct → partages via chaque team du user → public. Single query avec Condition::any(), pas de N+1. |
shared_with_user | (db, user_id, resource_type) → Result<Vec<(resource::Model, resource_share::Model)>> | Ressources partagees directement (partage user, pas team). |
accessible_by_user | (db, user_id, resource_type) → Result<Vec<resource::Model>> | Toutes les ressources accessibles (possedees + partagees directement + publiques). |
Modele resource_share::Model :
id: Uuid,resource_id: Uuid,permission_level: String,shared_by_user_id: Uuid,created_atshared_with_user_id: Option<Uuid>,shared_with_team_id: Option<Uuid>,shared_with_remote_identity_id: Option<Uuid>— exactement un des trois estSome(contrainte CHECK + 3 partial unique indexes). La 3ᵉ colonne est ajoutée par la migrationm20260510_000014(RFC 0008) et reste àNonesur tout déploiement sans fédération.
Cross-instance :
Principaln'est pas étendu — un partage avec une identité fédérée passe par l'écriture directe d'une rowresource_share { shared_with_remote_identity_id: Some(remote_id) }(RFC 0009). L'API helper côté framework n'est pas encore figée ; pour l'instant les apps consommatrices écrivent via SeaORM en attendant une RFC dédiée. Leeffective_permission_for_userreste user-only — les remote shares se résolvent côté handler fédération.
Exemple d'utilisation dans un projet consommateur :
use rustwarden_core::prelude::*; // Enregistrer un "wall" PasterMan comme ressource ResourceService::register(&db, "wall", wall.id, user.user_id).await?; // Partager en lecture avec un user ResourceService::share_with(&db, "wall", wall.id, Principal::User(alice), "read", user.user_id).await?; // Partager avec une team (tous les membres heritent) ResourceService::share_with(&db, "wall", wall.id, Principal::Team(backend_team_id), "write", user.user_id).await?; // Permission effective (prend en compte user + teams, max) match ResourceService::effective_permission_for_user(&db, visitor, "wall", wall.id).await? { Some(p) if p == "owner" => /* acces total */, Some(p) if p == "admin" => /* edition + partage */, Some(p) if p == "write" => /* edition */, Some(p) if p == "read" => /* lecture */, _ => /* pas d'acces */, }
9. Teams (groupes d'utilisateurs)
TeamService
Module : rustwarden_core::services::team_service
Export : prelude (TeamService, TeamRole)
Groupes d'utilisateurs avec membership, owner, role par membre. Un Principal::Team(id) est accepte par ResourceService::share_with — tous les membres heritent alors du share via effective_permission_for_user.
Enum TeamRole : Owner | Admin | Member — persiste en DB comme "owner"|"admin"|"member". Le createur est automatiquement insere comme owner member au create.
| Methode | Signature | Description |
|---|---|---|
create | (db, owner_id, slug: TeamSlug, name: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<team::Model> | Cree une team + insere l'owner comme premier membre (role Owner). Cable on_team_created. Valide name (1-128 chars). |
find_by_id | (db, team_id) → Result<Option<team::Model>> | Recherche par ID. |
find_by_slug | (db, slug: &TeamSlug) → Result<Option<team::Model>> | Recherche par slug. |
add_member | (db, team_id, user_id, role: TeamRole, hooks) → Result<team_member::Model> | Idempotent : update le role si l'edge existe, insere sinon. Cable on_team_member_added. |
add_member_identity | (db, team_id, identity: Identity, role: TeamRole, hooks) → Result<team_member::Model> | RFC 0009 — variante Identity-aware. Pour Identity::Local délègue à add_member ; pour Identity::Remote(remote_id) insère une row avec user_id = NULL et remote_identity_id = Some(remote_id). L'unicité est garantie par team_members_remote_unique (partial index, migration m20260510_000015). |
teams_of_identity | (db, identity: Identity) → Result<Vec<team::Model>> | RFC 0009 — variante Identity-aware de teams_of_user. |
remove_member | (db, team_id, user_id, hooks) → Result<()> | Supprime un membre. Refuse de retirer l'owner (AppError::Validation) — utiliser delete pour drop la team. Cable on_team_member_removed. |
list_members | (db, team_id) → Result<Vec<team_member::Model>> | Liste les membres, tri desc par joined_at. |
teams_of_user | (db, user_id) → Result<Vec<team::Model>> | Les teams dont le user est membre (inner join). |
team_ids_of_user | (db, user_id) → Result<Vec<Uuid>> | Juste les IDs — helper perf pour la resolution de permission. |
delete | (db, team_id, hooks) → Result<()> | Hard-delete la team. Cascade DB sur team_members + resource_shares avec ce team comme principal. Cable on_team_deleted. |
PK relâchée pour la fédération : depuis la migration
m20260510_000016, la PK historique(team_id, user_id)est remplacée par une PK synthétiqueid: Uuid, etteam_members.user_iddevientNULLABLE(avecpgcryptoactivé). Indispensable pour porter une identité fédérée (Identity::Remote) sans utilisateur local. Les apps qui requêtent directementteam_membersdoivent toléreruser_id IS NULL.
Invitations cross-instance : voir §21.6 —
FediverseInviteServicegère le flow d'invitationInvite{Membership}→Accept/Rejectavec les 6 endpoints/api/v1/teams/{id}/invites/*et/api/v1/fediverse/invites/*.
TeamSlug (value object)
Module : rustwarden_core::types::team_slug
Export : prelude
Slug URL-safe pour une team. Construction via TeamSlug::parse(&str) qui valide :
- 1 a 64 caracteres
[a-z0-9-]+(lowercase ASCII + digits + hyphen)- Ni debut ni fin avec
- - Pas un des ~15 noms reserves (
admin,api,teams,settings, ...)
use rustwarden_core::prelude::*; let slug = TeamSlug::parse("backend")?; let team = TeamService::create(&db, owner_id, slug, "Backend Team", Some(&hooks)).await?; TeamService::add_member(&db, team.id, alice_id, TeamRole::Member, Some(&hooks)).await?; // Partager un repo avec la team : ResourceService::share_with(&db, "repo", repo_id, Principal::Team(team.id), "write", owner_id).await?; // Alice herite automatiquement du write access : let perm = ResourceService::effective_permission_for_user(&db, alice_id, "repo", repo_id).await?; assert_eq!(perm.as_deref(), Some("write"));
Modeles :
team::Model:id,slug: String,name: String,owner_id: Uuid,created_atteam_member::Model(PK composite(team_id, user_id)) :team_id,user_id,role: String,joined_at
Cascade :
- DELETE user → supprime les teams dont il est owner (cascade) et ses memberships.
- DELETE team → supprime
team_members+resource_sharesviashared_with_team_id.
Integration consommateur :
TeamServiceest framework-only. Les apps consommatrices exposent leurs propres handlers/routes/templates team selon leur UX. Voir DEVELOPER.md §12 — Hooks teams pour brancher un audit log sur les evenements team.
10. Configuration applicative
AppSettingsService
Module : rustwarden_core::services::app_settings_service
Stockage cle-valeur en base pour la configuration dynamique.
| Methode | Signature | Description |
|---|---|---|
get_setting | (db, key: &str) → Result<Option<app_settings::Model>> | Lire un parametre. |
set_setting | (db, key: String, value: String, updated_by: Option<Uuid>) → Result<Model> | Creer ou mettre a jour un parametre. |
get_all_settings | (db) → Result<Vec<Model>> | Lire tous les parametres. |
get_bool | (db, key: &str, default: bool) → Result<bool> | Lire un booleen avec valeur par defaut. |
get_bool_with_fallback | (db, keys: &[&str], default: bool) → Result<bool> | Lire un booleen avec cles de fallback. |
get_server_config | (db, config_name: &str) → Result<Option<String>> | Lire une config serveur (*:server_config:{config_name}). |
set_server_config | (db, app_id: &str, config_name: &str, value: String) → Result<Model> | Ecrire une config serveur ({app_id}:server_config:{config_name}). |
initialize_default_settings | (db) → Result<()> | Seed les ~30 parametres par defaut. Idempotent. Appele automatiquement par build(). |
Parametres par defaut (extrait) :
| Cle | Defaut | Description |
|---|---|---|
app_name | "Rustwarden" | Nom de l'application |
app_domain | "" | Domaine public |
registration_enabled | "true" | Inscription ouverte |
email_verification_required | "true" | Verification email obligatoire |
password_min_length | "12" | Longueur minimale du mot de passe |
password_require_uppercase | "true" | Exiger une majuscule |
oauth_enabled | "false" | OAuth active |
analytics_enabled | "false" | Mesure d'audience active — §23 |
analytics_site_id | "" | Identifiant de site du fournisseur — §23 |
analytics_attributes | "{}" | Attributs data-* additionnels, objet JSON — §23 |
L'URL du script de suivi est absente de cette table : elle alimente la CSP, figée au démarrage, et reste donc une variable d'environnement. Voir §23.2.
11. Preferences utilisateur
UserPreferencesService
Module : rustwarden_core::services::user_preferences_service
| Methode | Signature | Description |
|---|---|---|
get_preferences | (db, user_id: Uuid) → Result<Option<Model>> | Lire les preferences. |
create_preferences | (db, user_id: Uuid, theme: String, language: String) → Result<Model> | Creer les preferences. |
update_preferences | (db, user_id: Uuid, theme: String, language: String) → Result<Model> | Mettre a jour les preferences. |
create_default_preferences | (db, user_id: Uuid) → Result<Model> | Creer les preferences par defaut (theme: "light", language: "fr"). |
Valeurs acceptees :
theme:"light","dark","auto"language:"fr","en","de","es"
12. Email
EmailService
Module : rustwarden_core::services::email_service
Transport SMTP de bas niveau.
| Methode | Signature | Description |
|---|---|---|
new | (config: EmailConfig) → Result<Self> | Cree le service avec connexion SMTP. |
send_email | (&self, to_email, to_name: Option<&str>, subject, body_html, body_text) → Result<(Option<String>, Option<String>)> | Envoie un email. Retourne (smtp_code, smtp_response). |
validate_config | (config: &EmailConfig) → Result<()> | Valide la configuration SMTP. |
EmailQueueService
Module : rustwarden_core::services::email_queue_service
File d'attente d'emails persistante en base (envoi asynchrone par tache de fond).
| Methode | Signature | Description |
|---|---|---|
enqueue | (db, to_email, to_name, from_email, from_name, subject, body_html, body_text, email_type) → Result<Model> | Ajoute un email a la file. |
get_pending | (db, limit: usize) → Result<Vec<Model>> | Recupere les emails en attente (plus ancien en premier). |
mark_sent | (db, id: Uuid, smtp_code, smtp_response) → Result<()> | Marque comme envoye. |
mark_failed | (db, id: Uuid, error_message, smtp_code, smtp_response) → Result<()> | Marque comme echoue. |
increment_attempts | (db, id: Uuid) → Result<()> | Incremente le compteur de tentatives. |
get_all | (db, status_filter, email_type_filter, limit, offset) → Result<Vec<Model>> | Liste avec filtres et pagination. |
record_status | (db, email, state, smtp_code, smtp_response, ...) → Result<()> | Enregistre le statut de livraison dans email_delivery_status. |
list_delivery_statuses | (db, recipient, email_type, state, since, limit) → Result<Vec<Model>> | Liste les statuts de livraison avec filtres. |
EmailValidationService
Module : rustwarden_core::services::email_validation_service
Verification d'email par token.
| Methode | Signature | Description |
|---|---|---|
new | (config: EmailConfig) → Self | Constructeur. Depuis 1.0.1 le rate limiter interne est un OnceLock partage (signature inchangee). |
send_validation_email | (&self, db, user_id: Uuid) → Result<()> | Envoie l'email de verification. Invalide les tokens precedents. |
verify_email | (&self, db, token: &str) → Result<Uuid> | Verifie l'email via le token. Passe email_verified = true. Envoie un email de bienvenue. |
resend_verification | (&self, db, email: &str) → Result<bool> | Renvoie l'email de verification. Rate limited : check_sliding_window("email_verify:{email}", 3, 1h) → 429 + Retry-After. |
send_validation_email | (&self, db, user_id: Uuid) → Result<()> | Envoie l'email de verification. Invalide les tokens precedents. |
verify_email | (&self, db, token: &str) → Result<Uuid> | Verifie l'email via le token. Passe email_verified = true. Envoie un email de bienvenue. |
resend_verification | (&self, db, email: &str) → Result<bool> | Renvoie l'email de verification. Rate limited : check_sliding_window("email_verify:{email}", 3, 1h) → 429 + Retry-After. |
ImapBounceService
Module : rustwarden_core::services::imap_bounce_service
Detection des bounces email via IMAP (tache de fond).
| Methode | Signature | Description |
|---|---|---|
new | (config: EmailConfig, db: Arc<DatabaseConnection>) → Self | Constructeur. |
is_enabled | (&self) → bool | Verifie si le traitement IMAP est configure. |
start | (self) | Demarre la tache de fond (polling IMAP a intervalle configurable). |
Le service parse les messages DSN (RFC 3464) et met a jour la table email_delivery_status.
13. Chiffrement
CryptoService
Module : rustwarden_core::services::crypto_service
Chiffrement AES-256-GCM pour les secrets en base (secrets OAuth, etc.).
| Methode | Signature | Description |
|---|---|---|
encrypt | (plaintext: &str) → Result<String> | Chiffre. Retourne enc:<base64(nonce || ciphertext)>. |
decrypt | (value: &str) → Result<String> | Dechiffre. Si la valeur n'a pas le prefixe enc:, la retourne telle quelle (retrocompat). |
is_available | () → bool | Verifie si OAUTH_ENCRYPTION_KEY est configure (64 chars hex = 32 bytes). |
is_encrypted | (value: &str) → bool | Verifie le prefixe enc:. |
14. OAuth (feature oauth)
Disponible uniquement si la feature
oauthest activee dansCargo.toml.
OAuthService
Module : rustwarden_core::services::oauth::service
| Methode | Signature | Description |
|---|---|---|
find_oauth_account | (db, provider: &str, provider_user_id: &str) → Result<Option<Model>> | Trouve un compte OAuth lie. |
find_user_oauth_accounts | (db, user_id: Uuid) → Result<Vec<Model>> | Liste les comptes OAuth d'un utilisateur. |
link_account | (db, user_id: Uuid, provider: &str, info: &ProviderUserInfo) → Result<Model> | Lie un compte OAuth a un utilisateur. |
unlink_account | (db, user_id: Uuid, provider: &str) → Result<()> | Delie un compte OAuth. Refuse si c'est la seule methode d'authentification (pas de mot de passe et un seul lien OAuth). |
unlink_account_by_id | (db, user_id: Uuid, account_id: Uuid) → Result<()> | Delie par ID (meme protection). |
find_or_create_user | (db, provider: &str, info: &ProviderUserInfo) → Result<(user::Model, bool)> | Trouve ou cree un utilisateur depuis les infos OAuth. Retourne (user, is_new). Exige un email verifie du provider. |
Trait OAuthProvider
Module : rustwarden_core::services::oauth::provider_trait
Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
| Methode | Signature | Description |
|---|---|---|
name | (&self) → &str | Identifiant ("google", "github", "discord", "microsoft"). |
display_name | (&self) → &str | Nom affichable ("Google", "GitHub", etc.). |
authorization_url | (&self, redirect_uri: &str) → Result<AuthorizationUrl> | Genere l'URL d'autorisation avec state CSRF, nonce (OIDC) et challenge PKCE S256. Le pkce_verifier correspondant est conserve en session. |
exchange_code | (&self, code, redirect_uri, pkce_verifier, nonce) → Result<ProviderUserInfo> | Echange le code d'autorisation contre les infos utilisateur. Le pkce_verifier issu de la session est transmis au endpoint token (obligatoire depuis que GitHub impose PKCE, cf. RFC 0035). |
Providers implementes : Google (OIDC complet), GitHub (OAuth2 + REST API), Discord (OAuth2 + REST API), Microsoft (Azure AD avec support tenant).
PKCE (RFC 7636) — invariant sur les 4 providers
Tous les providers emettent un code_challenge=…&code_challenge_method=S256 a
l'autorisation et rejouent le code_verifier a l'echange :
- Google et Microsoft (OIDC) delèguent au crate
openidconnectviaset_pkce_verifier(...). - GitHub et Discord (OAuth2 + POST
reqwestmanuel) ajoutent le champcode_verifierau formulaire d'echange, uniquement s'il est present (rétro-compatible avec un flux sans PKCE). Cf. RFC 0035 : avant ce correctif le verifier etait ignore, ce qui cassait la connexion GitHub depuis que GitHub impose PKCE (14 juillet 2025).
Diagnostic : GitHub/Discord renvoient leurs erreurs sous forme de corps JSON (
{"error":…,"error_description":…}, parfois en HTTP 200 pour GitHub).exchange_codelit le corps en texte avant de le parser et remonte le message brut du provider (… token exchange rejected (HTTP …): …) au lieu d'une erreur de desserialisation opaque.
15. Rate limiting
RateLimitService
Module : rustwarden_core::services::rate_limit_service
Rate limiter generique in-process, backing de toutes les limites du framework (auth, public share, password flows, email validation) et reutilisable par les apps consommatrices.
| Methode | Signature | Description |
|---|---|---|
new | () → Self | Nouveau service (maps internes vides). |
check | (&self, key: &str, max_per_minute: u32) → Result<(), AppError> | Fixed window 60s. Renvoie AppError::TooManyRequests { retry_after_secs } au depassement. |
check_window | (&self, key: &str, max: u32, window: Duration) → Result<(), AppError> | Idem avec fenetre personnalisable. |
check_sliding_window | (&self, key: &str, max: u32, window: Duration) → Result<(), AppError> | Sliding window strict (stocke chaque timestamp). Preferable pour les flux securite sensibles (password reset, email verify). |
cleanup_loop | (self, interval: Duration, window: Duration) async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). Ne borne pas le nombre de cles : au plafond MAX_ENTRIES (100 000) l'insertion evince l'entree LRU au lieu de 429 sur une cle neuve (1.0.1). |
len / sliding_len / is_empty | Compteurs exposes pour metrics/tests. |
Cle convention : "<scope>:<identifier>" — ex. "auth:1.2.3.4", "share:1.2.3.4", "pwd_reset:user@example.com", "import:123e4567-...".
AppError::TooManyRequests : mappe vers HTTP 429 + header Retry-After: N + body JSON {"error","status":429,"retry_after_secs":N}.
RateLimitService est Clone (interieur Arc). Un new() par requete cree une map vide : la limite n'est jamais atteinte. Les services framework qui doivent limiter (reset, email verify) utilisent un OnceLock interne.
Exemple d'usage pour proteger un endpoint d'import cote app consommateur :
use std::sync::Arc; use rustwarden_core::services::rate_limit_service::RateLimitService; // Au demarrage de l'app — une seule instance, pas dans le handler let limiter = Arc::new(RateLimitService::new()); // Dans un handler limiter.check(&format!("import:{ip}"), 120)?; // 120/min
Les routes JSON /api/v1/auth/{login,register,refresh,forgot-password,2fa/verify,resend-verification*} sont protegees par un layer auth_ip_rate_limit (ConnectInfo / MockConnectInfo). check_auth_rate_limit(&HeaderMap) reste public (signature gelee) mais ne lit pas ConnectInfo : a n'utiliser que si vous extrayez deja l'IP, ou pour un fallback headers-only. Un login SSR hors auth_api_routes() n'herite pas du layer — il faut le limiter explicitement (helper ou RateLimitService partage + IP extraite du Request).
Integration consommateur : voir DEVELOPER.md §15 — Rate limiting et notifications temps-reel pour l'exemple d'integration dans un handler Axum.
16. Notifications temps-reel et SSE
Trait NotificationBus<T> et InMemoryBus
Module : rustwarden_core::notifications
Pub/sub bus generique pour pousser des evenements aux clients (Server-Sent Events, progress bars, live logs). Le trait est swappable (futur backend Redis) — aujourd'hui seul InMemoryBus est fourni.
#[async_trait] pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync { async fn publish(&self, event: T); fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>>; } pub type EventFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;
InMemoryBus<T> — backing tokio::sync::broadcast :
new(capacity)/Default(capacity 256).receiver_count()pour metrics.- Slow consumer drop proprement via
BroadcastStream::filter_map(pas de panic, pas de stream closure). - Publisher ignore
Errquand aucun receiver.
Helper sse::sse_response
Module : rustwarden_core::notifications::sse
pub const DEFAULT_SSE_KEEPALIVE: Duration = Duration::from_secs(30); pub fn sse_response<T, S>(stream: S) -> Sse<impl Stream<Item = Result<Event, Infallible>>> where T: Serialize + Send + 'static, S: Stream<Item = T> + Send + 'static;
Wrap un stream en reponse axum Sse avec keep-alive 30s + serialisation JSON par event + drop silencieux des events non serialisables.
Exemple complet (cote app) :
use std::sync::Arc; use axum::extract::Extension; use axum::response::IntoResponse; use rustwarden_core::notifications::{InMemoryBus, NotificationBus, sse::sse_response}; #[derive(Clone, serde::Serialize)] enum AppEvent { ImportProgress { job_id: uuid::Uuid, pct: u8 }, CiLog { job_id: uuid::Uuid, line: String }, } async fn import_stream( Extension(bus): Extension<Arc<dyn NotificationBus<AppEvent>>>, axum::extract::Path(job_id): axum::extract::Path<uuid::Uuid>, ) -> impl IntoResponse { let filter = Arc::new(move |e: &AppEvent| matches!(e, AppEvent::ImportProgress { job_id: j, .. } if *j == job_id)); sse_response(bus.subscribe(filter)) }
Integration consommateur : voir DEVELOPER.md §15 — Notifications temps-reel (SSE) pour l'injection du bus dans le builder et l'abonnement filtre.
17. Hooks lifecycle et audit
Trait RustwardenHooks
Module : rustwarden_core::hooks
Export : prelude
Trait implementable par les apps consommatrices pour reagir aux evenements lifecycle du framework. Toutes les methodes ont un default Ok(()) — overridez celles qui vous interessent.
Event lifecycle (19 hooks) :
| Hook | Signature (after &self, db: &DatabaseConnection) | Description |
|---|---|---|
on_user_registered | user_id, username | Nouveau user cree. |
on_user_login | user_id, username | Login reussi. |
on_user_logout | user_id | Logout. |
on_user_deleted | user_id, username | Avant cascade DB — cleanup des ressources metier (walls, repos, ...). |
on_password_changed | user_id | Mot de passe modifie (flow direct ou confirme). |
on_email_verified | user_id | Email verifie. |
on_role_changed | user_id, roles: &[String] | Roles modifies. |
on_totp_enabled / on_totp_disabled | user_id | 2FA toggle. |
on_oauth_login | user_id, provider, is_new_user | Login OAuth. |
on_resource_shared | rt, rid, shared_with, level, shared_by | Ressource partagee. |
on_resource_unshared | rt, rid, user_id | Partage revoke. |
on_resource_registered | rt, rid, owner_id | Nouvelle ressource enregistree. |
on_resource_unregistered | rt, rid | Ressource supprimee du registre. |
on_team_created | team_id, owner_id | Team creee. |
on_team_deleted | team_id | Team supprimee. |
on_team_member_added / on_team_member_removed | team_id, user_id | Membership. |
on_audit_event | event: AuditEvent<'_> | Escape hatch generique pour events qui ne meritent pas leur propre hook. |
Struct AuditEvent<'a>
pub struct AuditEvent<'a> { pub kind: &'a str, // "auth.login_blocked", "pr.merged", ... pub actor_id: Option<Uuid>, pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) pub metadata: &'a serde_json::Value, }
Emis par :
AuthLoggingService::log_blocked_login→kind="auth.login_blocked", metadata{email, reason}.- Apps consommatrices pour leurs propres events (issues, PR, walls, ...).
Les hooks implementant on_audit_event recoivent tous ces events : un sink unique (SIEM, Langfuse, indexation OpenSearch) se branche une fois pour tout auditer.
Exemple d'implementation cote app :
use rustwarden_core::hooks::{AuditEvent, RustwardenHooks}; use async_trait::async_trait; use sea_orm::DatabaseConnection; use uuid::Uuid; pub struct MyHooks; #[async_trait] impl RustwardenHooks for MyHooks { async fn on_user_deleted(&self, _db: &DatabaseConnection, user_id: Uuid, _: &str) -> anyhow::Result<()> { // Cleanup du repertoire utilisateur sur le filesystem std::fs::remove_dir_all(format!("/var/data/users/{user_id}")).ok(); Ok(()) } async fn on_audit_event(&self, _db: &DatabaseConnection, event: AuditEvent<'_>) -> anyhow::Result<()> { // Forward vers un SIEM externe my_siem::send(event.kind, event.actor_id, event.metadata).await?; Ok(()) } }
Installation : RustwardenApp::builder().hooks(Arc::new(MyHooks)).build()?.
Integration consommateur : voir DEVELOPER.md §12 — Hooks pour l'exemple complet d'un
AuditHooksimplementant les 4 hooks teams.
Hook fédération distinct :
RustwardenHookscouvre les événements du framework cœur. Les événements fédération (signatures invalides, replay, SSRF, abus inbox, challenges d'auth, décisions d'invitation) passent par un trait dédiéFediverseEventObserver— voir §21.8. Les deux traits coexistent sans interférence : un mêmeArc<MyObserver>peut implémenter les deux si vous voulez un sink unique.
18. Middleware (CSRF, auth, rate limit)
CSRF
Depuis 2026-06, le framework câble la protection CSRF automatiquement. Vous n'avez plus à poser
csrf_protection*à la main pour le cas standard : il suffit de monter vos routes sur le bon canal. Le middleware reste exposé pour les montages personnalisés.
Câblage par canal (browser / machine) — le modèle à adopter
build() assemble deux sous-routeurs puis les fusionne :
| Canal | Méthode builder | CSRF | Pour quoi |
|---|---|---|---|
| browser | with_ui(fn), merge_browser_routes(router), merge_routes(router) (= browser par défaut) | Oui (layer appliqué par le framework) | Pages SSR pilotées par cookie de session : formulaires, écrans admin, actions mutantes rendues en HTML. |
| machine | merge_machine_routes(router) | Non | API JSON (auth Bearer), webhooks, callbacks, inbox ActivityPub — tout ce qui ne peut/ne doit pas porter de token CSRF. |
Le session_layer reste global et s'exécute avant le CSRF (la Session est donc disponible au moment du contrôle). L'exemption machine est topologique (le routeur machine ne porte structurellement pas le layer) — pas de flag « CSRF off ».
RustwardenApp::builder() .from_env() .with_ui(rustwarden_ui::browser_routes) // SSR → CSRF .merge_machine_routes(rustwarden_ui::api_routes()) // /api/v1/* → pas de CSRF .merge_browser_routes(my_app::ui_routes()) // vos pages mutantes → CSRF .merge_machine_routes(my_app::api_routes()) // votre API JSON / webhooks → pas de CSRF .build().await?;
Secure-by-default (B1). Une route mutante ajoutée sur le canal browser est protégée sans code supplémentaire dans le handler. Le mode d'échec par défaut est fail-safe (403 visible), jamais fail-open.
Délivrer le token côté client (routes browser)
Deux canaux acceptés, au choix :
- Header
X-CSRF-Token: <token>(SPA / HTMX /fetch). Pensez à l'ajouter dansallow_headersCORS (déjà fait côté framework). - Champ de formulaire
csrf_token=<token>enapplication/x-www-form-urlencoded(SSR sans JS). Le middleware bufferise le body, valide, puis reconstruit le body pour l'extracteurForm<…>aval.
Le token est obtenu via la session (get_or_create_csrf_token) et injecté dans le template — voir §20 Layouts (LayoutContext::from_session peuple csrf_token automatiquement).
API du module rustwarden_core::middleware::csrf
get_or_create_csrf_token(&Session) -> Result<String>— token de session, à injecter dans les templates.validate_csrf_token(&Session, &str)— validation manuelle depuis un handler (defense-in-depth ; le layer browser valide déjà).extract_csrf_from_form(&[u8]) -> Option<String>— parseur public pour les apps qui bufferisent le body elles-mêmes.csrf_protection,csrf_protection_with_config,csrf_protection_impl— middlewares bas niveau, uniquement pour un montage manuel sur un routeur que vous gérez vous-même (cas avancé ; le câblage par canal ci-dessus est préféré).
CsrfProtectionConfig (utilisé en interne par le canal browser avec accept_form_body: true) :
pub struct CsrfProtectionConfig { pub accept_form_body: bool, // default: false pub max_form_body_bytes: usize, // default: 1 MiB }
Montage manuel (routeur custom, hors canaux du builder) :
use std::sync::Arc; use axum::middleware; use rustwarden_core::middleware::csrf::{csrf_protection_with_config, CsrfProtectionConfig}; let cfg = Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() }); let router = Router::new() .route("/forms/submit", post(handler)) .layer(middleware::from_fn_with_state((db.clone(), cfg), csrf_protection_with_config));
Securite :
- Comparaison constant-time (SEC-H4,
subtle::ConstantTimeEq). - Skip pour méthodes sûres (
GET/HEAD/OPTIONS) et pourAuthorization: Bearer ...(clients API non CSRF-vulnérables). - Multipart jamais bufferisé (skip + 403).
- Body > cap → 413
PAYLOAD_TOO_LARGE.
Migration depuis l'ancienne API (avant 2026-06)
| Avant | Maintenant |
|---|---|
with_ui(rustwarden_ui::default_routes) | with_ui(rustwarden_ui::browser_routes) + merge_machine_routes(rustwarden_ui::api_routes()) |
merge_routes(api_router) (votre API JSON / webhooks) | merge_machine_routes(api_router) — sinon 403 |
merge_routes(ssr_router) (vos pages HTML mutantes) | inchangé (= browser/CSRF) ou explicite via merge_browser_routes(ssr_router) |
Vous posiez csrf_protection* à la main sur le routeur global | Retirez-le : le canal browser le pose pour vous (gardez-le uniquement sur un routeur custom non monté via les canaux) |
rustwarden_ui::default_routes() | déprécié → browser_routes() + api_routes() |
Check-list : (1) les pages SSR mutantes envoient le token (
X-CSRF-Tokenheader oucsrf_tokenform) ; (2) toute API JSON / webhook par cookie est surmerge_machine_routes; (3) les clients API utilisentAuthorization: Bearer(bypass CSRF) ; (4)cargo test+ un POST forgé cross-site → 403.
Rate limiting helpers
Module : rustwarden_core::middleware::rate_limit
Wrapper pratiques par-dessus RateLimitService :
| Helper | Signature | Description |
|---|---|---|
check_auth_rate_limit | (&HeaderMap) → Result<(), Response> | Helper headers-only (signature pub gelee). Cle auth:{ip} via X-Forwarded-For si TRUSTED_PROXY_HOPS > 0, sinon auth:unknown. Ne protege plus le router JSON : voir layer ci-dessous. Configurable via env RATE_LIMIT_AUTH_PER_MINUTE (default 10). |
check_public_share_rate_limit | (&Request) → Result<(), Response> | Protege les endpoints de public share. Cle share:{ip}, 30/min. Lit ConnectInfo. |
check_public_share_rate_limit_from_headers | (&HeaderMap) → Result<(), Response> | Idem depuis un HeaderMap (extractor deja consomme le Request). |
extract_client_ip / extract_client_ip_from_headers | IP utilisateur : extract_client_ip(&Request) lit ConnectInfo puis MockConnectInfo (tests Axum 0.8), puis XFF. extract_client_ip_from_headers ignore ConnectInfo. | |
AdminRateLimitConfig::* | Layers Tower pre-construits (admin_limit, user_management_limit, reset_password_limit). |
auth_api_routes() pose un layer auth_ip_rate_limit sur login, register, refresh, forgot-password, 2fa/verify, resend-verification*. Logout, me, reset-password et verify-email (token) restent hors quota IP. Derriere un reverse proxy, TRUSTED_PROXY_HOPS doit etre >= 1 sinon la cle est l'IP du hop (souvent 127.0.0.1).
RustwardenApp::run() sert via into_make_service_with_connect_info::<SocketAddr>(). Un consommateur qui appelle axum::serve(listener, router) sans ConnectInfo retombe sur auth:unknown (un seul seau pour tout le monde).
Auth / Admin
Module : rustwarden_core::middleware
auth_required::require_auth— middleware qui exige un JWT valide (rejette 401 sinon).admin_required::require_admin— exige le roleadmin.auth_service::auth_middleware— auth primaire (extraction JWT + validation + injectionClaimsen Extension).password_complexity::enforce_password_complexity— helper de validation.
Préfixes protégés & mode d'échec (⚠ changement 2026-08)
L'auth middleware ne protège que les préfixes enregistrés. Depuis 2026-08, l'ensemble par défaut est vide (auparavant /home, /profile, /test-base, /settings, /admin) :
with_ui(..)enregistre automatiquement les zones UI/home,/profile,/settings,/admin— une app UI reste protégée sans rien changer./adminet/api/v1/adminrestent en plus protégés indépendamment parAdminService.- Une app headless (sans
with_ui) ne protège que ce qu'elle déclare viaregister_protected_prefix("/…").
⚠ Migration au prochain
update-project(apps headless uniquement) : si votre app sert des pages sous/home,/profileou/settingsen comptant sur les anciens défauts, ajoutez lesregister_protected_prefix(..)correspondants — sinon ces pages deviennent publiques./admin/*reste couvert parAdminService. Contrôle :grep -rn 'register_protected_prefix' src/puis comparer avec vos.route("/home | /profile | /settings …").
Mode d'échec sur préfixe protégé atteint sans credentials valides — AuthConfig.auth_failure_mode :
Redirect(défaut UI) :303verslogin_path(défaut/login, envLOGIN_PATHou builder.login_path("/…")).Json401:401 {"error":"unauthorized"}..headless()bascule le défaut surJson401.
Précédence (RFC 0042, 1.2.0) : .auth_failure_mode(AuthFailureMode::…) > env AUTH_FAILURE_MODE=redirect|json401 > bascule .headless() > Redirect. Même logique pour .login_path(..) > LOGIN_PATH > /login. Points d'application : préfixes protégés (AuthService), /admin* et /api/v1/admin* (AdminService), extracteurs AuthUser / auth_required / admin_required, et root_redirect de rustwarden-ui.
⚠ Avant 1.2.0,
AuthServiceetAdminServiceignoraient ces overrides (ils relisaientAuthConfig::from_env()à chaque requête, cf. RFC 0042) : une app headless sansAUTH_FAILURE_MODErépondait303 → /login. Depuis 1.2.0 elle répond401. Si vous composez votre pile tower à la main, utilisezAuthService::with_auth_config(inner, db, prefixes, auth_config)etAdminService::with_auth_config(inner, db, auth_config); les constructeursnewsont dépréciés. Détail :auth-middleware-layering.md.
Router auth JSON prêt à monter
rustwarden_core::auth::auth_api_routes() -> Router<DatabaseConnection> monte les endpoints /api/v1/auth/* (login, register, refresh, logout, me, forgot/reset-password, verify-email, resend-verification, 2fa/verify). À poser sur le canal machine :
.headless() .merge_machine_routes(rustwarden_core::auth::auth_api_routes())
Les projets générés par le CLI en mode headless câblent déjà ce router (+ un endpoint protégé d'exemple whoami via l'extracteur AuthUser). rustwarden_ui::api_routes() monte le même set.
Le rate-limit IP du layer s'applique aux routes d'attaque (login, register, refresh, forgot-password, 2fa/verify, resend). Un handler HTML /login hors ce router n'est pas couvert — à limiter côté app (ConnectInfo ou extract_client_ip sur le Request).
En-tetes de securite (SecurityHeadersConfig)
Module : rustwarden_core::config::security_headers
Export : prelude
RFC : 0039 — depuis 1.1.0
Le middleware security_headers_middleware pose les en-tetes de securite sur
toutes les reponses. Depuis 1.1.0, la CSP est configurable et COOP/CORP sont
disponibles en opt-in.
#[derive(Clone, Debug)] pub struct SecurityHeadersConfig { pub content_security_policy: Cow<'static, str>, pub cross_origin_opener_policy: Option<Cow<'static, str>>, pub cross_origin_resource_policy: Option<Cow<'static, str>>, }
| Element | Signature / valeur | Description |
|---|---|---|
SecurityHeadersConfig::default() | — | CSP = DEFAULT_CONTENT_SECURITY_POLICY, COOP/CORP = None |
with_same_origin_isolation | (self) → Self | Positionne COOP et CORP a same-origin. La CSP n'est pas touchee. |
allowing_script_origin | (self, origin: &str) → Self | Ajoute origin a script-src et connect-src. Idempotent ; une directive absente est creee depuis default-src. Voir §23.7. |
DEFAULT_CONTENT_SECURITY_POLICY | &'static str | La CSP historique 1.0.1 (avec 'unsafe-inline' et 'unsafe-eval') |
CSP_WITHOUT_UNSAFE_EVAL | &'static str | Candidat Phase 2, identique au defaut moins 'unsafe-eval'. Non utilise par Default. |
RustwardenBuilder::security_headers | (cfg: SecurityHeadersConfig) → Self | Override au builder |
ℹ️ Interaction avec
.analytics(..). Quand une URL de script de suivi est configuree,build()appliqueallowing_script_origina la fin, sur la config resultante. L'ordre des appels.analytics(..)et.security_headers(..)au builder est donc indifferent : un.security_headers(..)tardif ne peut pas faire disparaitre silencieusement l'autorisation. Voir §23.7.
✅ Le defaut est bit-identique a 1.0.1. Un consommateur qui n'appelle jamais
.security_headers(..)— ni.analytics(..)avec une URL de script — ne voit aucun changement d'en-tete — ni amelioration automatique, ni regression. Le durcissement du defaut (retrait de'unsafe-eval') est une Phase 2 ulterieure, conditionnee a la migration vers la build Alpine CSP (plans/PLAN_CSP_HTMX.md).
| En-tete | Configurable ? | Defaut |
|---|---|---|
Content-Security-Policy | Oui | DEFAULT_CONTENT_SECURITY_POLICY |
Cross-Origin-Opener-Policy | Oui, opt-in | non emis (None) |
Cross-Origin-Resource-Policy | Oui, opt-in | non emis (None) |
X-Content-Type-Options | Non | nosniff |
X-Frame-Options | Non | DENY |
X-XSS-Protection | Non | 0 |
Referrer-Policy | Non | strict-origin-when-cross-origin |
Permissions-Policy | Non | camera=(), microphone=(), geolocation=() |
Strict-Transport-Security | Non | max-age=31536000; includeSubDomains, sauf si APP_DEBUG=true |
use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig let app = RustwardenApp::builder() .from_env() .security_headers( SecurityHeadersConfig { content_security_policy: "default-src 'self'; script-src 'self'; style-src 'self'; \ img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \ form-action 'self'" .into(), ..SecurityHeadersConfig::default() } .with_same_origin_isolation(), ) .build() .await?;
Details d'implementation :
- la config est capturee dans un
Arca la construction du layer, pas lue depuis uneExtension: ce layer est plus externe que lesExtension(...), qui ne sont pas encore posees quand la requete le traverse ; - une CSP invalide (caracteres de controle) ne panique pas : fallback sur le defaut +
warn!; - une valeur COOP/CORP invalide fait sauter l'en-tete +
warn!, sans casser la reponse.
Pieges : une CSP sans 'unsafe-inline' / 'unsafe-eval' casse rustwarden-ui
(HTMX + build Alpine standard) — a reserver aux apps headless ou a UI maison.
COOP/CORP same-origin cassent les integrations cross-origin (iframes, CDN
d'assets, widgets tiers).
A retirer cote consommateur : overlay
SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …) et tout header
COOP/CORP pose a la main — grep -rn "SetResponseHeaderLayer" src/.
19. Utilitaires
AuthLoggingService
Module : rustwarden_core::services::auth_logging
| Methode | Signature | Description |
|---|---|---|
log_blocked_login | (db, email: &str, reason: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<()> | Enregistre dans audit_logs + fan-out on_audit_event(kind="auth.login_blocked", metadata={email,reason}) si hooks fournis. Best-effort — un echec hook/DB logue un warn sans casser le flux auth. |
email_sanitization
Module : rustwarden_core::services::email_sanitization
| Fonction | Signature | Description |
|---|---|---|
sanitize_email_content | (content: &str) → String | Echappe les caracteres HTML (&, <, >, ", '). |
is_valid_email_format | (email: &str) → bool | Validation RFC d'email via le crate validator. |
default_admin_service
Module : rustwarden_core::services::default_admin_service
| Fonction | Signature | Description |
|---|---|---|
ensure_default_admin | (db) → Result<()> | Cree l'administrateur par defaut au demarrage si aucun admin n'existe. Lit ADMIN_USERNAME, ADMIN_EMAIL, ADMIN_PASSWORD. Appele automatiquement par build(). |
AdminConfig::validate() (placeholders connus + PasswordRules) ne s'applique que sur le chemin create. Si un admin existe deja, un ADMIN_PASSWORD placeholder ou trop faible n'empeche plus le boot (1.0.1). Sans ADMIN_PASSWORD, le bootstrap est ignore (pas de generation automatique). Le template CLI .env.example n'embarque plus de mot de passe placeholder.
AuthConfig refuse au demarrage un JWT_SECRET egal (pas un sous-chaine) a un placeholder connu, y compris REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE.
sanitize_redirect (middleware auth, query redirect) normalise \ en / avant le test // — un open redirect /\evil.com est bloque (1.0.1).
TOTP (2FA)
Module : rustwarden_core::services::totp_service
Depuis 1.0.1, sur le chemin login :
create_challengeapplique un plafond somme des tentatives : 15 sur les 15 dernieres minutes par utilisateur (a la place de l'ancien compteur de challenges satures) →AppError::TotpMaxAttemptsExceeded;- 5 tentatives maximum par challenge, TTL 5 min ;
verify_code: garde anti-rejeu in-process (fenetre ±1, TTL 90 s) ;- codes backup verifies en
spawn_blocking(bcrypt).
Anti-rejeu persistant last_used_step (RFC 0037, depuis 1.1.0)
⚠️ Migration a jouer :
m20260918_000010_totp_last_used_stepajouteuser_totp.last_used_step BIGINT NULL. Jouer la migration avant de deployer le binaire 1.1.0 — un replica sur un schema pre-migration echoue a la verification TOTP (fail-closed : l'erreur DB remonte, l'utilisateur ne peut plus valider son 2FA).
verify_code et verify_and_enable consomment desormais le pas qui a
reellement matche (matching_totp_step, skew ±1 — jamais now / 30) via un
compare-and-set SeaORM :
UPDATE user_totp SET last_used_step = $step, updated_at = now() WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < $step)
Succes ssi rows_affected == 1.
| Aspect | Comportement |
|---|---|
| Rejeu detecte | Ok(false) — identique a un code invalide, pas d'oracle de rejeu |
| Multi-replicas | Ferme : le CAS SQL est partage, la garde in-process de 1.0.1 ne couvrait qu'un processus |
verify_and_enable | Consomme le pas avant de basculer enabled = true : le code de setup ne peut pas servir de login immediat |
| Erreur DB | Propagee en AppError — jamais fail-open |
| Garde in-process | Conservee apres le CAS, en defense en profondeur |
| Lignes existantes | last_used_step IS NULL = aucun pas consomme depuis la migration → le premier code presente apres migration est toujours accepte |
Ce que ca ne couvre pas : un MITM temps reel (proxy de phishing type Evilginx) rejoue le code avant l'utilisateur legitime et gagne le CAS. Le TOTP n'est pas phishing-resistant par construction. Voir developer-guide/07-security-model.md.
A retirer cote consommateur : tout patch local dupliquant l'anti-rejeu TOTP.
20. Patterns communs
Gestion d'erreurs
Tous les services retournent Result<T, AppError>. Les variantes principales :
| Variante | HTTP | Usage |
|---|---|---|
AppError::NotFound(msg) | 404 | Ressource introuvable |
AppError::Validation(msg) | 400 | Donnees invalides |
AppError::Forbidden | 403 | Acces refuse (RBAC, ownership) |
AppError::Unauthorized | 401 | Non authentifie |
AppError::InvalidToken | 401 | JWT / PAT / token reset/verify invalide |
AppError::TokenExpired | 401 | JWT expire |
AppError::TokenReused | 401 | Refresh token reutilise (attaque detectee) |
AppError::TooManyRequests { retry_after_secs } | 429 | Rate limit depasse. Header Retry-After: N ajoute automatiquement. |
AppError::Conflict(msg) | 409 | Unicite violee (username, email) |
AppError::Internal(msg) | 500 | Erreur interne |
AppError::Config(msg) | 500 | Configuration manquante |
Rate limiting
Toutes les limites passent par RateLimitService avec des cles prefixees (single source of truth, pas de duplication) :
| Cle | Limite | Window type | Expose via |
|---|---|---|---|
auth:{ip} | env RATE_LIMIT_AUTH_PER_MINUTE (default 10/min) | Fixed | Layer auth_ip_rate_limit sur auth_api_routes() (ConnectInfo). Helper check_auth_rate_limit(headers) = fallback headers-only. |
share:{ip} | 30/min | Fixed | check_public_share_rate_limit* |
pwd_change:{user_id} | 3/hour (config) | Sliding strict | PasswordChangeService::create_pending_request |
pwd_reset:{email} | 3/hour | Sliding strict | PasswordResetService::request_reset |
email_verify:{email} | 3/hour | Sliding strict | EmailValidationService::resend_verification |
* (vos cles) | libre | les deux | RateLimitService::check* public |
Un depassement renvoie systematiquement AppError::TooManyRequests { retry_after_secs } → HTTP 429 + Retry-After.
Tokens hashes en base
Les tokens sensibles sont toujours stockes en SHA-256 en base. Le plaintext n'est retourne qu'a la creation et jamais persiste. Concerne :
- Refresh tokens (rotation, detection reuse).
- Password reset / email validation / password change (tokens email).
- Personal Access Tokens (PATs, static long-lived).
Revocation en cascade (SEC-M7)
Les operations critiques revoquent automatiquement tous les refresh tokens de l'utilisateur :
PasswordChangeService::confirm_request()PasswordResetService::reset_password()UserService::admin_reset_password()/change_password_direct()
Principal generique (User ou Team)
Les partages acceptent Principal::User(uuid) | Principal::Team(uuid). La resolution de permission (effective_permission_for_user) prend le max sur user direct + tous les teams dont l'user est membre — en une seule query SQL avec Condition::any() (pas de N+1).
Observabilite via hooks
Plutot que de forker le framework pour ajouter du monitoring, implementez RustwardenHooks :
- 18 hooks cibles (
on_user_*,on_team_*,on_resource_*, ...) pour de la business logic. on_audit_event(AuditEvent<'_>)comme escape hatch generique —AuthLoggingServicel'emet deja pourauth.login_blocked.
Layouts (CSRF + metadata)
LayoutContext::from_session(&session, title) peuple automatiquement csrf_token, app_name et app_version depuis la session. Retire le boilerplate get_or_create_csrf_token dans les handlers SSR :
use rustwarden_core::templates::LayoutContext; let layout = LayoutContext::from_session(&session, "Dashboard").await;
Cela force la reconnexion sur tous les appareils apres un changement de mot de passe.
21. Fédération ActivityPub (features fediverse*)
Statut :
experimental-fediverse— l'API publique peut casser sans bump majeur jusqu'à promotion stable (cf.fediverse-consumer-guide.md). RFCs 0001-0025.Pour le câblage (
main.rs, features Cargo, migrations, env vars), voirfediverse-consumer-guide.md. Cette section couvre uniquement les APIs publiques des services exposés.
Périmètre & crates
| Crate | Feature app | Rôle |
|---|---|---|
rustwarden-fediverse | fediverse | Protocole ActivityPub headless (services, routes, signatures). Aucune UI. |
rustwarden-fediverse-ui | fediverse-ui (⇒ fediverse) | UI HTML de référence (handlers admin/user, templates Askama, dashboard card). |
rustwarden-fediverse-guard | fediverse-guard (⇒ fediverse) | Détection d'abus runtime, ban manager, observer (RFC 0028). |
Aucun re-export via rustwarden_core::prelude. Importez explicitement depuis rustwarden_fediverse::*.
21.1 FediverseConfig
Module : rustwarden_fediverse::config
Configuration immuable de la fédération, chargée au démarrage. RFC 0001.
| Méthode | Signature | Description |
|---|---|---|
from_env | () → Result<Self, FediverseConfigError> | Charge depuis l'environnement. Refuse les placeholders (example.com, localhost.localdomain, your.tld) et exige HTTPS sauf si FEDIVERSE_DEBUG_LOCAL_FEDERATION=true. |
domain | (&self) → &str | Domaine canonique de l'instance. |
base_url | (&self) → &Url | URL de base (https://{domain}/). |
user_agent | (&self) → &str | UA envoyé sur les requêtes sortantes. |
authorized_fetch | (&self) → bool | Mode authorized-fetch (récupération signée d'acteurs distants). |
outbound_workers | (&self) → usize | Nombre de workers outbound (réservé). |
outbound_max_attempts | (&self) → u32 | Default 8 tentatives avant Dead. |
outbound_request_timeout | (&self) → Duration | Default 30 s par tentative. |
fetch_actor_cache_ttl | (&self) → Duration | Default 24 h sur remote_identities. |
auth_challenge_ttl | (&self) → Duration | Default 5 min sur un challenge AP. |
debug_local_federation | (&self) → bool | Ne jamais true en prod — autorise HTTP et désactive certains contrôles. |
Variables d'environnement : voir fediverse.md §3.
21.2 FediverseTrustService
Module : rustwarden_fediverse::services::fediverse_trust_service
Allowlist + blocklist + mode de confiance, persistés en DB (fediverse_trusted_domains, fediverse_blocked_domains, app_settings). RFC 0004.
| Méthode | Signature | Description |
|---|---|---|
new | (db) → Self | Construit le service. |
is_enabled | (&self) → Result<bool> | Lit app_settings.fediverse_enabled. |
trust_mode | (&self) → Result<TrustMode> | Allowlist (défaut, zero-trust) ou OpenWithBlocklist (Mastodon-like). |
interop_scope | (&self) → Result<InteropScope> | Périmètre interopérable (auth-only / auth+invites / full). |
is_domain_allowed | (&self, domain: &str) → Result<bool> | Décide si un domaine peut fédérer selon le mode courant. |
assert_domain_allowed | (&self, domain: &str) → Result<()> | Variante errorante. À utiliser dans les handlers entrants. |
add_trusted_domain | (&self, domain: &str, label: Option<&str>, scope: Option<InteropScope>) → Result<()> | Ajoute à l'allowlist. |
remove_trusted_domain | (&self, domain: &str) → Result<bool> | Retire de l'allowlist (retourne true si supprimé). |
list_trusted_domains | (&self) → Result<Vec<TrustedDomain>> | Liste complète. |
add_blocked_domain | (&self, domain: &str, reason: &str) → Result<()> | Ajoute à la blocklist. |
remove_blocked_domain | (&self, domain: &str) → Result<bool> | Retire de la blocklist. |
list_blocked_domains | (&self) → Result<Vec<BlockedDomain>> | Liste complète. |
21.3 ActorKeyService
Module : rustwarden_fediverse::services::actor_key_service
Génération / lecture / persistence des clés RSA-2048 d'acteur local, chiffrées avec ENCRYPTION_KEY (AES-GCM). RFC 0005.
| Méthode | Signature | Description |
|---|---|---|
generate | () → Result<ActorKeyMaterial> | Génère une paire RSA-2048 (PEM public + PEM privé chiffré). ~100 ms CPU. |
public_key_for_user | (db, user_id: Uuid) → Result<Option<String>> | Retourne le PEM public si déjà persisté, sinon None. |
ensure_keys | (db, user_id: Uuid) → Result<ActorKeyMaterial> | Idempotent : génère + persiste si absent, retourne l'existant sinon. Appelé à la première requête /users/{u} ou webfinger. |
Sécurité : sans ENCRYPTION_KEY (64 hex), ensure_keys refuse de générer. Aucun écriture en clair de clé privée en DB.
21.4 FediverseOutboundService + worker
Module : rustwarden_fediverse::services::fediverse_outbound_service
File d'activités sortantes avec retry exponentiel (5 min → 30 min → 2 h → 6 h → 24 h → 48 h, cap 8). RFC 0010, 0011, 0019.
| Méthode | Signature | Description |
|---|---|---|
FediverseOutboundService::enqueue | (activity: serde_json::Value, target_inbox: &str, target_domain: &str) → Result<Uuid> | Insère une row Pending dans fediverse_outbound_queue. Retourne l'activity_id. |
FediverseOutboundService::drain_one<T: OutboundDelivery> | (&self, transport: &T) → Result<Option<Outcome>> | Pop la plus ancienne row prête à livrer, tente avec transport, met à jour Delivered / Pending (avec next_retry_at) / Dead. Utilisée par le worker. |
OutboundStatus::next_retry_offset | (attempts: i32) → Duration | Pure function. Expose le ladder de retry pour les tests / l'UI. |
OutboundWorker::new | (db, transport: T, poll_interval: Duration) → Self | Construit le worker (T implémente OutboundDelivery). |
OutboundWorker::run | (self) → impl Future<Output = ()> | Boucle infinie ; à lancer dans tokio::spawn. |
Trait OutboundDelivery :
#[async_trait] pub trait OutboundDelivery: Send + Sync { async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError>; }
Deux implémentations livrées :
| Implémentation | Module | Usage |
|---|---|---|
NoopDelivery | services::fediverse_outbound_service | Logue + return Ok. Tests / dev. |
HttpSignedDelivery | http_signatures | Signe RSA-SHA256 draft-cavage avec la clé privée du local actor, POST réel sur target_inbox. À utiliser en prod. |
Observabilité : 4 événements tracing sous target rustwarden::fediverse::outbound : delivered / deferred / dead / drain_failed. Champs : activity_id, target_inbox, target_domain, attempts, max_attempts, last_error, next_retry_at (RFC 0019).
21.5 FediverseSignalService
Module : rustwarden_fediverse::services::fediverse_signal_service
Application des signaux lifecycle entrants sur l'état local (cache remote_identities, memberships, JWT blacklist). RFC 0010.
| Méthode | Signature | Description |
|---|---|---|
apply_update_actor | (db, actor: UpdatedActor) → Result<remote_identity::Model> | Upsert dans remote_identities (insert si inconnu, update sinon). Retourne la row finale. |
apply_delete_actor_with_hooks | (db, ap_id: &str, hooks: DeleteActorHooks) → Result<DeleteOutcome> | Entrypoint canonique (RFC 0033). Flip disabled_at = NOW(), puis si hooks.iterator est Some blackliste les JWT actifs (RFC 0018), puis si hooks.observer est Some fire on_delete_actor post-commit (RFC 0032). Idempotent : seules les transitions actif→désactivé déclenchent les hooks. |
apply_delete_actor | (db, ap_id) → Result<DeleteOutcome> | Wrapper de compatibilité — équivalent à apply_delete_actor_with_hooks(db, ap_id, DeleteActorHooks::default()). |
apply_delete_actor_observed | (db, ap_id, observer: &dyn FediverseEventObserver) → Result<DeleteOutcome> | Wrapper de compatibilité (RFC 0032) — pose hooks.observer = Some(...). |
apply_delete_actor_with_iterator | (db, ap_id, iter: &dyn ActiveTokenIterator) → Result<DeleteOutcome> | Wrapper de compatibilité (RFC 0018) — pose hooks.iterator = Some(...). |
apply_revoke_membership | (db, team_id, remote_identity_id) → Result<bool> | Drop la row team_members correspondante. Retourne true si une row a été supprimée. |
Les apply_* sont idempotents et sûrs en concurrence : un même signal rejoué ne provoque pas d'effet de bord supplémentaire. Pour combiner observer + iterator (cleanup local et blacklist JWT), utiliser apply_delete_actor_with_hooks directement :
use rustwarden_fediverse::services::fediverse_signal_service::{ DeleteActorHooks, FediverseSignalService, }; FediverseSignalService::apply_delete_actor_with_hooks( &db, ap_id, DeleteActorHooks { observer: Some(&my_observer), iterator: Some(&my_iterator), }, ).await?;
Le pattern recommandé pour future-proofer la construction est le struct literal avec ..Default::default() (la struct n'est pas #[non_exhaustive] mais l'ajout futur de champs ne cassera pas les call-sites qui suivent ce pattern).
21.6 FediverseInviteService
Module : rustwarden_fediverse::services::fediverse_invite_service
State machine des invitations cross-instance (équipes locales → identité fédérée). RFC 0009, 0014.
| Méthode | Signature | Description |
|---|---|---|
invite_remote | (db, team_id, invited_handle: &str, role: TeamRole, invited_by_user_id: Uuid) → Result<fediverse_pending_invite::Model> | Résout @user@domain, crée une row fediverse_pending_invites (Pending), prépare l'Invite{Membership} AP à enqueue vers l'inbox de l'identité distante. |
accept | (db, invite_id, remote_identity_id) → Result<team_member::Model> | L'identité distante accepte. Bascule Accepted, insère la row team_members avec remote_identity_id, déclenche on_team_member_added. |
reject | (db, invite_id) → Result<fediverse_pending_invite::Model> | Bascule Rejected. Transition autorisée seulement depuis Pending. |
revoke | (db, invite_id) → Result<fediverse_pending_invite::Model> | Bascule Revoked (annulation par l'équipe inviteuse). |
list_pending_for_team | (db, team_id) → Result<Vec<fediverse_pending_invite::Model>> | Vue admin équipe. |
list_pending_for_handle | (db, handle: &str) → Result<Vec<fediverse_pending_invite::Model>> | Vue user (sa boîte d'invitations entrantes). |
Endpoints HTTP correspondants : 6 routes sous /api/v1/teams/{id}/invites/* et /api/v1/fediverse/invites/* montées par router_with_auth (cf. §21.13).
21.7 FediverseAuthService
Module : rustwarden_fediverse::services::fediverse_auth_service
State machine du flow d'authentification AP-native : un user d'une instance distante se logue chez vous. RFC 0006, 0013, 0025.
| Méthode | Signature | Description |
|---|---|---|
initiate | (db, request: InitiateRequest, ttl: Duration) → Result<InitiatedChallenge> | Démarre un challenge sortant : résout l'acteur dérivé du handle, génère un nonce, persiste fediverse_auth_challenges (TTL fourni). Retourne le challenge prêt à diffuser. |
record_decision | (db, challenge_id, decision: ChallengeDecision) → Result<fediverse_auth_challenge::Model> | L'IdP (acteur du user) consent ou refuse. Bascule l'état local ; l'émission de l'AuthApproval / AuthDenial AP est faite par le handler appelant. |
persist_inbound_request | (db, body: &serde_json::Value, idp_domain: &str) → Result<fediverse_auth_challenge::Model> | Réception d'un AuthRequest AP signé. Parse le payload, vérifie la cible, persiste pour décision du user local via consent UI. |
record_inbound_decision | (db, body: &serde_json::Value) → Result<()> | Réception d'une réponse AP (AuthApproval / AuthDenial) signée du back-channel (RFC 0025). Met à jour le challenge correspondant. |
status | (db, challenge_id) → Result<fediverse_auth_challenge::Model> | Poll pour le client SPA : retourne la row courante (état + métadonnées). |
consume | (db, config: &AuthConfig, challenge_id) → Result<ConsumedSession> | Échange un challenge approuvé contre un JWT remote (identity_kind=Remote, remote_id peuplé). Marque le challenge Consumed (one-shot). |
Endpoints HTTP : /api/v1/auth/login/fediverse/initiate, /fediverse/auth/inbox, /api/v1/fediverse/auth/consent, /api/v1/auth/login/fediverse/status, /api/v1/auth/login/fediverse/consume.
21.8 FediverseEventObserver
Module : rustwarden_fediverse::observer
Trait d'observation des événements fédération. Hook unique côté framework, branché par axum::Extension sur le routeur. Distinct de RustwardenHooks (qui couvre le cœur).
#[async_trait] pub trait FediverseEventObserver: Send + Sync { async fn on_signature_failure( &self, _domain: &str, _actor_uri: Option<&str>, _reason: &str, ) {} async fn on_replay_attempt( &self, _domain: &str, _actor_uri: Option<&str>, _details: serde_json::Value, ) {} async fn on_fetch_ssrf(&self, _target_host: &str, _attempted_ip: Option<&str>) {} async fn on_inbox_abuse( &self, _domain: &str, _actor_uri: Option<&str>, _reason: &str, ) {} async fn on_auth_challenge(&self, _domain: &str, _handle: &str) {} async fn on_invite_action(&self, _domain: &str, _decision: &str) {} }
Toutes les méthodes ont un default {} — overridez celles que vous écoutez. Deux implémentations livrées :
| Impl | Module | Usage |
|---|---|---|
NoopFediverseObserver | rustwarden_fediverse::observer | Zero-cost no-op. Default si la feature fediverse-guard n'est pas activée. |
GuardObserver | rustwarden_fediverse_guard | Détection d'abus runtime + ban manager (RFC 0028). |
Installation : fediverse_router.layer(axum::Extension(observer)) (cf. fediverse-consumer-guide.md §4).
21.9 LocalUserResolver
Module : rustwarden_fediverse::resolver
Trait de résolution @handle → UserSummary côté local, consommé par WebFinger / acteur / NodeInfo. RFC 0002.
#[async_trait] pub trait LocalUserResolver: Send + Sync { async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError>; async fn count_users_total(&self) -> Result<u64, AppError>; async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError>; async fn count_local_posts(&self) -> Result<u64, AppError>; }
Struct UserSummary (champs publics) : id: Uuid, username: String, display_name: Option<String>, bio: Option<String>, ap_public_key_pem: Option<String>.
Implémentation par défaut : SeaOrmLocalUserResolver::new(db). Si votre app a un mapping custom (ex. acteur = team), implémentez le trait et injectez-le à router_with_auth. Les méthodes count_* alimentent NodeInfo (/.well-known/nodeinfo).
21.10 SignatureVerifier
Module : rustwarden_fediverse::services::fediverse_signal_service
Trait de vérification de signatures HTTP entrantes (inbox + auth-back-channel). RFC 0015, 0016, 0017.
#[async_trait] pub trait SignatureVerifier: Send + Sync { async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; }
Deux implémentations livrées :
| Impl | Module | Mode |
|---|---|---|
HttpSigVerifier | rustwarden_fediverse::http_signatures | Production. RSA-SHA256 draft-cavage, fenêtre de skew Date 5 min (ANSSI F-CRYPTO-1), résolution auto du PEM via remote_identities (RFC 0017). |
AlwaysAcceptVerifier | services::fediverse_signal_service | Testbed uniquement. Vérifie que l'acteur résout, ne valide pas la signature. À gater derrière FEDIVERSE_DEBUG_LOCAL_FEDERATION=true. |
Constructeurs :
HttpSigVerifier::with_resolver(db) // prod AlwaysAcceptVerifier::with_resolver(db) // testbed AlwaysAcceptVerifier::new() // tests unitaires
Les deux exposent .with_observer(Arc<dyn FediverseEventObserver>) pour notifier en cas d'échec.
21.11 RemoteIdentityRevocationChecker
Module : rustwarden_core::middleware::revocation
Trait consommé par le middleware d'auth pour rejeter un JWT remote dont la remote_identity a été disabled. RFC 0018.
#[async_trait] pub trait RemoteIdentityRevocationChecker: Send + Sync { async fn is_revoked(&self, remote_identity_id: Uuid) -> bool; }
Implémentation livrée : rustwarden_fediverse::services::revocation::SeaOrmRemoteIdentityRevocationChecker::new(db). Lit remote_identities.disabled_at IS NOT NULL.
Installation : builder.register_revocation_checker(Arc::new(...)). Sans cet appel, un Delete{Actor} reçu ne révoque pas les JWT remote actifs — faille de sécurité.
21.12 Identity / IdentityKind
Module : rustwarden_core::identity
pub enum IdentityKind { Local, Remote } pub enum Identity { Local(Uuid), // user_id Remote(Uuid), // remote_identity_id }
Abstraction utilisée par les services qui acceptent indifféremment un user local ou une identité fédérée (TeamService::add_member_identity, teams_of_identity — cf. §9). Le champ Claims.identity_kind côté JWT (§4) permet aux handlers de matcher dynamiquement.
21.13 Routes prêtes à monter
Module : rustwarden_fediverse::routes
Deux constructeurs de routeur. Tous deux retournent Router<DatabaseConnection>.
| Fonction | Signature | Routes montées |
|---|---|---|
router | (config: FediverseConfig, db: DatabaseConnection) → Router<DatabaseConnection> | Minimum : /.well-known/webfinger, /.well-known/nodeinfo, /.well-known/host-meta, /nodeinfo/2.0, /users/{username}, /inbox. Pas d'auth, pas d'invites. |
router_with_auth | (config, resolver: Arc<dyn LocalUserResolver>, verifier: Arc<dyn SignatureVerifier>, auth_config: Arc<AuthConfig>) → Router<DatabaseConnection> | Complet : router(..) + 5 endpoints /api/v1/auth/login/fediverse/* + /fediverse/auth/inbox + /api/v1/fediverse/auth/consent + 6 endpoints invites + /fediverse/consent + /fediverse/invite. |
Canal CSRF (cf. §18). Ces routeurs portent
POST /inbox(authentifié par signature HTTP, aucun token CSRF possible) → ils se montent surbuilder.merge_machine_routes(..), jamais sur le canal browser.
Côté rustwarden-fediverse-ui : default_routes() retourne le routeur UI (admin trust/blocks/outbound + écrans user consent/login). Ce sont des formulaires SSR pilotés par cookie → famille browser : montez-le sur builder.merge_browser_routes(rustwarden_fediverse_ui::default_routes()) (le layer CSRF du framework les protège ; chaque handler conserve son validate_csrf_token en defense-in-depth). dashboard_card() reste injectable via builder.add_dashboard_card(..). Idem pour rustwarden_fediverse_guard::ui::default_routes() (écrans /admin/fediverse-guard/*) → merge_browser_routes, tandis que l'observer guard reste branché sur le routeur fédération machine.
21.15 RemoteResourceShareService
Module : rustwarden_fediverse::services::remote_resource_share_service
Sharing de ressources métier (repo, document, …) avec une identité remote sans passer par une team. Companion de ResourceService §8 — le core gère les principals User/Team, ce service gère le principal Remote(remote_identity_id). Table dédiée fediverse_resource_shares, schéma dans la migration m20260516_000002_remote_resource_shares. RFC 0029.
pub const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"];
| Méthode | Signature | Description |
|---|---|---|
share | (db, resource_type: &str, resource_id: Uuid, remote_identity_id, permission_level: &str, shared_by_user_id) → Result<Model> | Upsert par (resource_id, remote_identity_id). Refuse une ressource inconnue (NotFound) ou un permission_level hors hiérarchie (Validation). |
unshare | (db, resource_type, resource_id, remote_identity_id) → Result<bool> | Supprime la row. Retourne true si une row a été retirée, false sinon. |
effective_permission_for_remote_identity | (db, resource_type, resource_id, remote_identity_id) → Result<Option<String>> | Résout la permission effective sur la ressource — Some("read"|"write"|"admin") ou None. |
list_shares | (db, resource_type, resource_id) → Result<Vec<Model>> | Tous les remote shares d'une ressource (UI admin). Tri desc par created_at. |
list_for_remote_identity | (db, remote_identity_id) → Result<Vec<Model>> | Ressources accessibles par une identité distante (UI "my access"). |
Composition côté consommateur :
use rustwarden_core::identity::Identity; use rustwarden_core::services::resource_service::ResourceService; use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService; let level = match identity { Identity::Local(uid) => ResourceService::effective_permission_for_user(&db, uid, "repo", repo_id).await?, Identity::Remote(rid) => RemoteResourceShareService::effective_permission_for_remote_identity(&db, "repo", repo_id, rid).await?, };
Asymétrie volontaire avec §8 : les permissions
User/Teamvivent dansresource_shares(core) ; les permissionsRemotevivent dansfediverse_resource_shares(fediverse). LePrincipalenum reste à 2 variants. La colonneresource_shares.shared_with_remote_identity_idhéritée de la RFC 0008 est non utilisée — RFC 0031 (à venir) la marquera#[deprecated].
21.16 RemotePersonalAccessTokenService<S>
Module : rustwarden_fediverse::services::remote_personal_access_token_service
Émission, vérification et révocation de Personal Access Tokens pour des identités remote. Companion de PersonalTokenService §7 — même crypto (SHA-256 + constant-time compare via subtle), même trait TokenScope, owner = remote_identities.id au lieu de users.id. Table dédiée fediverse_personal_access_tokens, schéma dans la migration m20260516_000003_remote_pat. RFC 0030.
pub const TOKEN_PREFIX: &str = "rwfed_pat_"; // distinct du core
Generic over le trait TokenScope du framework (rustwarden_core::services::personal_token_service::TokenScope). Le consommateur réutilise sa hiérarchie de scopes existante (e.g. RepoScope côté gitrust).
| Méthode | Signature | Description |
|---|---|---|
create | (db, remote_identity_id, name: &str, scopes: &[S], expires_at) → Result<(Model, String)> | Émet un PAT. Retourne le plaintext une seule fois (rwfed_pat_<64 hex>). Refuse name vide / > 64 chars (Validation), remote_identity_id inconnu (NotFound), identité distante disabled_at (Validation). |
verify | (db, plaintext: &str) → Result<Option<(Model, Vec<S>)>> | Parse le préfixe + lookup + check active + check disabled_at du remote. Retourne None pour toute défaillance, jamais Err sauf erreur DB. Touche last_used_at sur succès (best-effort). |
revoke | (db, token_id) → Result<bool> | Soft-revoke (set revoked_at). Idempotent — retourne true si la row passe de actif à révoqué, false sinon. |
list_for_remote_identity | (db, remote_identity_id) → Result<Vec<Model>> | Tokens non révoqués d'une identité distante (UI settings). Tri desc par created_at. |
revoke_all_for_remote_identity | (db, remote_identity_id) → Result<u64> | Bulk soft-revoke — câblé sur cascade Delete{Actor}. Retourne le nombre de rows mises à jour. |
Routing par préfixe côté consommateur :
if plaintext.starts_with(TOKEN_PREFIX) { RemotePersonalAccessTokenService::<MyScope>::verify(&db, &plaintext).await? .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s)) } else { PersonalTokenService::<MyScope>::verify(&db, &plaintext).await? .ok() .map(|(m, s)| (Identity::Local(m.user_id), s)) }
Cascade
Delete{Actor}:revoke_all_for_remote_identityest l'API qu'un consommateur câble depuis son impl deFediverseEventObserver::on_delete_actor(RFC 0032/0033) pour neutraliser les PAT d'une identité distante désactivée. La pipeline framework fournit le signal post-commit ; la révocation reste de la responsabilité du consommateur (le framework ne sait pas quels artefacts locaux dépendent de la remote identity).
21.14 Patterns d'intégration
Pour le câblage complet du main.rs, les variables d'environnement, les migrations et la check-list d'adoption, voir fediverse-consumer-guide.md. Récap des bonnes pratiques côté code :
- Toujours brancher un
RemoteIdentityRevocationCheckersi vous activezfediverse— sans lui,Delete{Actor}n'invalide rien. - Toujours utiliser
HttpSigVerifieren prod —AlwaysAcceptVerifierne doit être atteint qu'avecFEDIVERSE_DEBUG_LOCAL_FEDERATION=true(guard explicite dansmain). - Toujours
tokio::spawn(OutboundWorker::run())au démarrage, avec unHttpSignedDeliveryinjecté —NoopDeliveryne fait que logger. - Toujours s'abonner au target tracing
rustwarden::fediverse::outbound— sinon vous découvrirez lesdeadpar les clients qui râlent. - Étendre via les 5 traits (
LocalUserResolver,SignatureVerifier,FediverseEventObserver,RemoteIdentityRevocationChecker,OutboundDelivery) plutôt que forker. Les anti-patterns sont listés dansfediverse-consumer-guide.md§10.
22. Binaire screen-select — plan des écrans UI
Le binaire screen-select est livré avec rustwarden-core. Il cible les projets consommateurs qui n'embarquent pas rustwarden-ui (mode headless / UI custom) et permet de planifier puis d'auditer la couverture des écrans à réaliser.
# Depuis le répertoire racine du projet consommateur cargo run -p rustwarden-core --bin screen-select # mode wizard (interactif) cargo run -p rustwarden-core --bin screen-select -- --check # mode vérification (non-interactif)
Mode wizard
Sans argument, screen-select démarre un assistant interactif sur stdin :
- Sélection parmi 17 fonctionnalités optionnelles du catalogue (Authentification est toujours incluse — 18 fonctionnalités au total, 22 écrans).
- Chemin optionnel vers un fichier
design.mdet chaîne de layout personnalisée. - Génère
docs/plans/ui-screens.mddans le projet consommateur — tableau Markdown avec colonnes : Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O.
Comportement de fusion (re-run) : les statuts (☑/☐) et les routes éditées à la main sont préservés par identité (fonctionnalité + écran). Les nouveaux écrans sont ajoutés ; ceux des fonctionnalités désélectionnées sont retirés.
Mode --check
Scanne les sources .rs du projet consommateur en excluant target/ et les crates rustwarden-* vendorisées, à la recherche de littéraux de route exacts ("<route>"). Met à jour la colonne Statut du tableau (☐/☑) et affiche :
Couverture : N/M écrans réalisés. Écrans manquants : ☐ Fonctionnalité — Écran (/route/suggérée)
Source de vérité
Le catalogue (fonctionnalité → écrans, routes suggérées, méthodes core, canal browser/machine, protection) réside dans crates/rustwarden-core/src/bin/screen-select/catalogue.rs en tant que const CATALOGUE. Des tests (catalogue_invariants, catalogue_couvre_les_fonctionnalites_attendues) en gardent la complétude.
Limites connues
| Limite | Détail |
|---|---|
| Détection textuelle | Un .route("/x") commenté est compté comme implémenté. |
| Fichiers non-Rust | Les routes déclarées hors .rs (TOML, YAML, macros procédurales séparées) ne sont pas vues. |
| Écrasement des ☑ manuels | --check réécrit le statut à partir du scan — les coches posées à la main sont réinitialisées à chaque exécution. |
Le CLI rustwarden-cli invite à exécuter screen-select à l'étape 4 de start-project et dans les notes finales de update-project.
Sections de sécurité du plan
Le plan généré porte, en plus du tableau des écrans :
- Sécurité transversale (obligatoire, hors écran) — garde-fous globaux à
câbler une fois : middleware de headers (CSP,
X-Frame-Options,X-Content-Type-Options,Referrer-Policy), validation de configuration au démarrage (refus deADMIN_PASSWORD=password, alerteAPP_DEBUG/COOKIE_SECURE), cookiesHttpOnly/Secure/SameSite,cargo audit+ JWT en HMAC. - Conventions de rendu sûr — escaping contextuel (jamais de valeur utilisateur
dans un
<script>:data-*+dataset),textContentplutôt qu'innerHTML,encodeURIComponent/urlenc(), pas de filtre|safe. - Contrat de sécurité par écran — pour chaque écran sélectionné : niveau
d'autorisation (Public / Authentifié / Propriétaire de ressource / Administrateur),
rappel CSRF automatique pour les écrans browser, et pièges spécifiques
(IDOR/ownership,
ensure_admin(), anti-énumération, token de reset hors contexte JS…).
Ces sections sont délimitées par des marqueurs HTML
(<!-- screen-select:security-guides -->, <!-- screen-select:security-per-screen -->)
et régénérées à chaque exécution du wizard. --check ne modifie que le tableau :
les sections de sécurité sont préservées telles quelles.
23. Mesure d'audience
Module : rustwarden_core::config::analytics
Export : prelude (AnalyticsConfig, AnalyticsContext, AnalyticsHandle, AnalyticsState)
UI : rustwarden_ui::analytics::AnalyticsOptOutContext + components/analytics_opt_out.html
Support générique d'un script de suivi tiers, modélisé comme une URL de script
plus un jeu d'attributs data-*. Cette forme couvre Rybbit, Plausible, Umami,
Matomo-tag et leurs équivalents sans embarquer une ligne de code spécifique à un
fournisseur : seuls les noms d'attributs changent.
Par défaut, rien n'est activé : aucun script rendu, CSP inchangée au caractère près.
23.1 AnalyticsConfig
#[derive(Clone, Debug, PartialEq, Eq)] pub struct AnalyticsConfig { pub enabled: bool, pub script_url: Option<String>, pub site_id: Option<String>, pub site_id_attribute: String, // défaut "data-site-id" pub attributes: BTreeMap<String, String>, pub opt_out_storage_key: Option<String>, pub defer: bool, // défaut true }
| Element | Signature | Description |
|---|---|---|
default() | — | Suivi désactivé, aucune URL. Ne rend rien, ne touche pas la CSP. |
from_env | () → Result<Self, AnalyticsConfigError> | Lit les variables ANALYTICS_* (§23.4). Aucune variable posée ⇒ default(). |
validate | (&self) → Result<(), AnalyticsConfigError> | Porte de sécurité : URL, charset des valeurs, noms d'attributs, clé d'opposition. |
script_origin | (&self) → Option<String> | scheme://host[:port], ce que la CSP doit autoriser. Disponible même si enabled == false. |
render_snippet | (&self) → Option<String> | La balise <script>, ou None si désactivé / sans URL. Valeurs échappées en HTML. |
with_script_url / with_site_id / with_site_id_attribute / with_attribute / with_opt_out_storage_key / enabled | (self, ..) → Self | Setters chaînables. |
with_attributes_json | (self, raw: &str) → Result<Self, AnalyticsConfigError> | Remplace les attributs depuis un objet JSON de chaînes (format stocké en base). |
RustwardenBuilder::analytics | (cfg: AnalyticsConfig) → Self | Câblage au builder. |
23.2 Répartition environnement / base
⚠️ L'URL du script n'est pas modifiable depuis l'administration. Elle vient de l'environnement ou du builder et reste figée jusqu'au redémarrage.
security_headers_middlewarecapture sa configuration une seule fois, dans unArcconstruit aubuild()et placé hors de toute coucheExtension(contrainte assumée de la RFC 0039). Une URL éditable à chaud laisserait la CSP désynchronisée jusqu'au redémarrage suivant — le script serait tout simplement bloqué par le navigateur.C'est aussi une propriété de sécurité : un compte d'administration compromis ne peut pas élargir la CSP vers un domaine arbitraire, ce qui constituerait une primitive d'exfiltration.
| Réglage | Source | Modifiable à chaud |
|---|---|---|
| URL du script | ANALYTICS_SCRIPT_URL / builder | Non (alimente la CSP) |
| Attribut portant l'identifiant | ANALYTICS_SITE_ID_ATTRIBUTE / builder | Non |
Clé d'opposition localStorage | ANALYTICS_OPT_OUT_STORAGE_KEY / builder | Non |
defer | ANALYTICS_DEFER / builder | Non |
| Activation | ANALYTICS_ENABLED, puis app_settings.analytics_enabled | Oui |
| Identifiant de site | ANALYTICS_SITE_ID, puis app_settings.analytics_site_id | Oui |
Attributs data-* | ANALYTICS_ATTRIBUTES, puis app_settings.analytics_attributes | Oui |
Une valeur stockée en base qui ne passerait pas validate() est ignorée avec un
tracing::warn!, et la configuration d'environnement reprend la main : un contenu
invalide en base ne peut ni casser le démarrage ni produire une balise douteuse.
23.3 AnalyticsState, AnalyticsContext, AnalyticsHandle
Le snippet est rendu au démarrage et à chaque rechargement, jamais pendant une
requête : snippet() n'est qu'un clone d'Arc, sans aller-retour base. C'est ce qui
rend l'appel abordable sur chaque rendu de page.
| Element | Signature | Description |
|---|---|---|
AnalyticsState::load | (db, base: AnalyticsConfig) → Result<Self, AppError> | Applique les surcharges app_settings et pré-rend le snippet. Appelé par build(). |
AnalyticsState::from_config | (base: AnalyticsConfig) → Self | Sans base de données (tests, apps sans réglages persistés). |
AnalyticsState::reload | (&self, db) → Result<(), AppError> | Relit les réglages et re-rend. À appeler après une sauvegarde admin. |
AnalyticsState::snippet | (&self) → Arc<Option<String>> | Le snippet pré-rendu. |
AnalyticsState::base_config | (&self) → &AnalyticsConfig | La configuration d'environnement, avant surcharge. |
AnalyticsContext | extracteur Axum, infaillible | { snippet, opt_out_storage_key, script_url } + is_enabled(), has_opt_out(), is_configured(). Contexte vide si le framework n'a pas câblé l'analytics. |
AnalyticsHandle | extracteur Axum, infaillible | Option<Arc<AnalyticsState>> + reload(), base_config(). Pour les rares appelants qui doivent rafraîchir l'état. |
Les deux extracteurs sont infaillibles par construction : une application montée à
la main, sans passer par RustwardenBuilder, continue de compiler et de servir — elle
obtient simplement un contexte vide.
23.4 Variables d'environnement
| Variable | Défaut | Description |
|---|---|---|
ANALYTICS_SCRIPT_URL | — | URL absolue du script. https:// obligatoire, sauf hôte loopback. |
ANALYTICS_ENABLED | false | true / 1 / on activent. Surchargeable en base. |
ANALYTICS_SITE_ID | — | Identifiant de site. Surchargeable en base. |
ANALYTICS_SITE_ID_ATTRIBUTE | data-site-id | Attribut portant l'identifiant (§23.5). |
ANALYTICS_ATTRIBUTES | — | Objet JSON d'attributs data-*. Surchargeable en base. |
ANALYTICS_OPT_OUT_STORAGE_KEY | — | Clé localStorage d'opposition. Vide ⇒ composant non rendu. |
ANALYTICS_DEFER | true | false / 0 / off retirent l'attribut defer. |
23.5 Correspondance des fournisseurs courants
Le modèle est générique ; ce tableau ne fait que nommer l'attribut attendu par chacun.
| Fournisseur | ANALYTICS_SITE_ID_ATTRIBUTE | Clé d'opposition usuelle |
|---|---|---|
| Rybbit | data-site-id (défaut) | disable-rybbit |
| Umami | data-website-id | — |
| Plausible | data-domain | plausible_ignore |
| Matomo-tag | data-site-id | — |
# Rybbit auto-hébergé, opposition activée ANALYTICS_SCRIPT_URL=https://stats.example.org/api/script.js ANALYTICS_SITE_ID=1d870795f7ae ANALYTICS_OPT_OUT_STORAGE_KEY=disable-rybbit ANALYTICS_ATTRIBUTES={"data-consent":"opt-in"} ANALYTICS_ENABLED=true
23.6 Contrat de rendu et |safe
Le framework ne pose pas le script dans le layout : c'est au template consommateur
de l'appeler, via l'extracteur AnalyticsContext.
// Handler pub async fn ma_page( analytics: AnalyticsContext, // .. ) -> Result<impl IntoResponse, AppError> { let template = MaPageTemplate { analytics, /* .. */ }; Ok(template) }
{% if analytics.is_enabled() %}{{ analytics.snippet|safe }}{% endif %}
ℹ️ Pourquoi
|safeici, alors que §22 proscrit ce filtre. La règle de §22 vise les valeurs utilisateur. Le champsnippetn'en est pas une : c'est du HTML produit par le framework, dont chaque valeur interpolée a d'abord été filtrée par liste blanche (validate) puis échappée en HTML (render_snippet). Les deux protections sont cumulatives, et testées séparément.La liste blanche est ce qui garde
onload,srcetintegrityhors de la balise : seuls des nomsdata-[a-z0-9-]+sont acceptés, et les valeurs sont restreintes à[A-Za-z0-9._:/,-]. Un administrateur peut donc ajouter des attributs, jamais du comportement.N'injectez jamais une saisie utilisateur dans ce champ : l'exception vaut pour la valeur produite par
render_snippet(), pas pour le filtre en général.
23.7 CSP
build() ajoute l'origine du script à script-src et connect-src — le navigateur
charge le script depuis cette origine, puis le script y renvoie ses beacons. L'opération
est idempotente et laisse les autres directives intactes ; une directive absente est
créée à partir de default-src, comme le ferait le navigateur.
L'ajout a lieu dans build(), pas dans le setter : l'ordre des appels
.analytics(..) et .security_headers(..) est donc indifférent, et un
.security_headers(..) tardif ne peut pas faire disparaître silencieusement
l'autorisation. Voir SecurityHeadersConfig::allowing_script_origin
(§18).
Sans analytics configuré, la CSP reste bit-identique au défaut historique.
23.8 Opposition du visiteur (RGPD)
La plupart des fournisseurs se neutralisent entièrement si une clé localStorage
disable-<namespace> existe. Le drapeau n'étant lu qu'à l'initialisation du script,
le changement prend effet au chargement de page suivant — ce que dit le libellé
traduit fourni.
let analytics_opt_out = AnalyticsOptOutContext::build(&analytics, &i18n_service, locale.as_str()).await;
{% include "components/analytics_opt_out.html" %}
Le composant ne rend rien si aucune clé n'est configurée : une page « vie privée »
peut l'inclure inconditionnellement. Traductions sous analytics.opt_out.* dans les six
locales livrées.
23.9 Limites connues
- Un seul fournisseur à la fois :
AnalyticsConfigne porte qu'une URL de script. - Pas de bannière de consentement. Un fournisseur qui en attend une se pilote via un
attribut (
data-consent), le recueil du consentement restant à la charge de l'application. - Pas de collecte côté serveur : le framework émet une balise, il ne mesure rien lui-même.
- Changer de fournisseur, ou son domaine, demande un redéploiement (§23.2).
GitRust