SERVICES_REFERENCE.md 136750 octets

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 maintenant merge_browser_routes (CSRF par défaut, fail-safe). Tout consommateur qui montait une API JSON / un webhook via merge_routes doit le migrer vers merge_machine_routes, sinon ces routes renverront 403 faute de token. Le routeur UI est partitionné : rustwarden_ui::default_routes() est déprécié au profit de browser_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 (§3 create_user / update_profile, §4 authenticate_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 SeaORM user_totp déclare last_used_step, donc sur un schéma pré-migration toute requête sur user_totp échoue, dont TotpService::is_totp_enabled appelé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 passent Username::parse ([a-z0-9-], 3–39, pas de majuscule) ; (3) update_profile n'écrit plus users.email : tout appel passant une nouvelle adresse renvoie AppError::Validation — basculer sur EmailChangeService::request_change (utilisateur) ou admin_force_change (opérateur, en remplacement de PUT /admin/users/{id} pour l'adresse), et vérifier que SMTP + le worker email_queue tournent avant le bump (§3) ; (4) le hook auth.login_blocked n'est plus émis par le blocage e-mail (la ligne audit_logs l'est toujours) ; (5) retirer les workarounds : newtype Username local, overlay SetResponseHeaderLayer::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 API pub). 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 (dont REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE) refuse le boot ; (3) database/.env doit définir POSTGRES_PASSWORD (plus de fallback compose) et PostgreSQL n'écoute que 127.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

  1. Architecture generale
  2. Acces aux services
  3. Gestion des utilisateurs
  4. Authentification
  5. RBAC — Roles et Permissions
  6. Mots de passe
  7. Tokens et sessions
  8. Ressources et partage
  9. Teams (groupes d'utilisateurs)
  10. Configuration applicative
  11. Preferences utilisateur
  12. Email
  13. Chiffrement
  14. OAuth
  15. Rate limiting
  16. Notifications temps-reel et SSE
  17. Hooks lifecycle et audit
  18. Middleware (CSRF, auth, rate limit)
  19. Utilitaires
  20. Patterns communs
  21. Fédération ActivityPub (features fediverse*)
  22. Binaire screen-select — plan des écrans UI
  23. 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 fn statiques, 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

MethodeSignatureDescription
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

ChampTypeDescription
idUuidIdentifiant unique
usernameStringNom d'utilisateur unique
emailStringEmail unique
password_hashStringHash bcrypt
email_verifiedboolEmail verifie
must_change_passwordboolForcer le changement au prochain login
last_login_atOption<DateTime>Derniere connexion
last_login_ipOption<String>IP de derniere connexion
created_atDateTimeDate de creation
updated_atDateTimeDerniere 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 :

CasComportement
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 couranteNo-op complet : le champ est ignore, le reste du profil est mis a jour normalement
Rename username seulInchange (RFC 0036)
Chemin adminIdentique : 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.

MethodeSignatureDescription
new(config: EmailConfig) → SelfConstruit le service.
with_i18n(self, i18n: Arc<I18nService>) → SelfTraduit 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.

MethodeCheminAuthEffet
POST/api/v1/auth/email-changeBearer + mot de passe courant dans le corpsouvre 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_change revoque 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_change apres verification d'identite hors bande. La faute de frappe a l'inscription releve du meme recours : register ecrit 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.

MethodeSignatureDescription
parse(raw: &str) → Result<Username, AppError>Valide charset, longueur, tirets et noms reserves. AppError::Validation (400) sinon.
as_str(&self) → &strVue empruntee.
into_inner(self) → StringConsomme le newtype (a passer a create_user).

Derive Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize (#[serde(transparent)]) et implemente Display.

Regles appliquees par parse :

RegleDetail
Longueur3 a 39 caracteres, mesures apres trim()
Charset[a-z0-9-] uniquement
MajusculesRejetees, jamais repliees — Alice echoue, il ne devient pas alice
TiretsNi en tete ni en queue, pas de -- consecutifs
Noms reservestypes::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).

MethodeSignatureDescription
Le parametre identifier accepte indifferemment un username ou un email (lookup par email s'il contient @).
MethodeSignatureDescription
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.

AspectValeur
Reglagevalidation_email_required (alias legacy validation_email_requise), lu via AppSettingsService::get_bool_with_fallback
Defauttrue — fail-closed : une erreur de lookup vaut « verification requise »
ErreurAppError::Validation → HTTP 400, message francais destine a l'utilisateur final (inchange depuis 1.0.1)
OrdreMot de passe verifie d'abord → pas d'oracle « compte existant mais non verifie »
AuditLigne 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
}
MethodeDescription
has_role(role: &str) → boolVerifie si l'utilisateur a un role.
has_any_role(roles: &[&str]) → boolVerifie si l'utilisateur a l'un des roles.
has_all_roles(roles: &[&str]) → boolVerifie si l'utilisateur a tous les roles.
has_permission(permission: &str) → boolVerifie 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

MethodeSignatureDescription
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)

ChampTypeDescription
subUuidUser ID
usernameStringNom d'utilisateur
rolesVec<String>Roles de l'utilisateur
permissionsVec<String>Permissions resolues
expi64Timestamp d'expiration
iati64Timestamp d'emission
issStringIssuer
identity_kindIdentityKindLocal (user en DB) ou Remote (identité fédérée). Sérialisé "local" / "remote". RFC 0007.
remote_idOption<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

MethodeSignatureDescription
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 :

RoleDescription
adminAdministrateur — acces complet, toutes les permissions implicites
userUtilisateur standard
app_managerGestionnaire d'application

PermissionService

Module : rustwarden_core::services::permission_service Export : prelude

MethodeSignatureDescription
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) :

CategoriePermissions
usersusers.view, users.manage, users.delete
rolesroles.view, roles.manage, roles.delete
settingssettings.view, settings.manage, settings.delete
auditaudit.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.

MethodeSignatureDescription
hash_passwordasync (password: &str) → Result<String>Hash bcrypt via spawn_blocking. Cout configurable via BCRYPT_COST (defaut: 12).
verify_passwordasync (password: &str, hash: &str) → Result<bool>Verification bcrypt via spawn_blocking.
generate_secure_password() → StringGenere un mot de passe de 20 caracteres avec mix garanti (majuscules, minuscules, chiffres, speciaux).
generate_default_password() → StringAlias 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.

MethodeSignatureDescription
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).

MethodeSignatureDescription
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)

MethodeSignatureDescription
generate_token() → StringGenere 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).

MethodeSignatureDescription
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) → StringHash 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::InvalidToken sur 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,
        }
    }
}
MethodeSignatureDescription
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> dans main.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

MethodeSignatureDescription
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).

MethodeSignatureDescription
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

MethodeSignatureDescription
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_at
  • shared_with_user_id: Option<Uuid>, shared_with_team_id: Option<Uuid>, shared_with_remote_identity_id: Option<Uuid> — exactement un des trois est Some (contrainte CHECK + 3 partial unique indexes). La 3ᵉ colonne est ajoutée par la migration m20260510_000014 (RFC 0008) et reste à None sur tout déploiement sans fédération.

Cross-instance : Principal n'est pas étendu — un partage avec une identité fédérée passe par l'écriture directe d'une row resource_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. Le effective_permission_for_user reste 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.

MethodeSignatureDescription
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étique id: Uuid, et team_members.user_id devient NULLABLE (avec pgcrypto activé). Indispensable pour porter une identité fédérée (Identity::Remote) sans utilisateur local. Les apps qui requêtent directement team_members doivent tolérer user_id IS NULL.

Invitations cross-instance : voir §21.6 — FediverseInviteService gère le flow d'invitation Invite{Membership} → Accept / Reject avec 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_at
  • team_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_shares via shared_with_team_id.

Integration consommateur : TeamService est 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.

MethodeSignatureDescription
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) :

CleDefautDescription
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

MethodeSignatureDescription
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.

MethodeSignatureDescription
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).

MethodeSignatureDescription
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.

MethodeSignatureDescription
new(config: EmailConfig) → SelfConstructeur. 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).

MethodeSignatureDescription
new(config: EmailConfig, db: Arc<DatabaseConnection>) → SelfConstructeur.
is_enabled(&self) → boolVerifie 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.).

MethodeSignatureDescription
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() → boolVerifie si OAUTH_ENCRYPTION_KEY est configure (64 chars hex = 32 bytes).
is_encrypted(value: &str) → boolVerifie le prefixe enc:.

14. OAuth (feature oauth)

Disponible uniquement si la feature oauth est activee dans Cargo.toml.

OAuthService

Module : rustwarden_core::services::oauth::service

MethodeSignatureDescription
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).

MethodeSignatureDescription
name(&self) → &strIdentifiant ("google", "github", "discord", "microsoft").
display_name(&self) → &strNom 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 openidconnect via set_pkce_verifier(...).
  • GitHub et Discord (OAuth2 + POST reqwest manuel) ajoutent le champ code_verifier au 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_code lit 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.

MethodeSignatureDescription
new() → SelfNouveau 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) asyncTache 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_emptyCompteurs 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 Err quand 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) :

HookSignature (after &self, db: &DatabaseConnection)Description
on_user_registereduser_id, usernameNouveau user cree.
on_user_loginuser_id, usernameLogin reussi.
on_user_logoutuser_idLogout.
on_user_deleteduser_id, usernameAvant cascade DB — cleanup des ressources metier (walls, repos, ...).
on_password_changeduser_idMot de passe modifie (flow direct ou confirme).
on_email_verifieduser_idEmail verifie.
on_role_changeduser_id, roles: &[String]Roles modifies.
on_totp_enabled / on_totp_disableduser_id2FA toggle.
on_oauth_loginuser_id, provider, is_new_userLogin OAuth.
on_resource_sharedrt, rid, shared_with, level, shared_byRessource partagee.
on_resource_unsharedrt, rid, user_idPartage revoke.
on_resource_registeredrt, rid, owner_idNouvelle ressource enregistree.
on_resource_unregisteredrt, ridRessource supprimee du registre.
on_team_createdteam_id, owner_idTeam creee.
on_team_deletedteam_idTeam supprimee.
on_team_member_added / on_team_member_removedteam_id, user_idMembership.
on_audit_eventevent: 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 AuditHooks implementant les 4 hooks teams.

Hook fédération distinct : RustwardenHooks couvre 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ême Arc<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 :

CanalMéthode builderCSRFPour quoi
browserwith_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.
machinemerge_machine_routes(router)NonAPI 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 dans allow_headers CORS (déjà fait côté framework).
  • Champ de formulaire csrf_token=<token> en application/x-www-form-urlencoded (SSR sans JS). Le middleware bufferise le body, valide, puis reconstruit le body pour l'extracteur Form<…> 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 pour Authorization: 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)

AvantMaintenant
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 globalRetirez-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-Token header ou csrf_token form) ; (2) toute API JSON / webhook par cookie est sur merge_machine_routes ; (3) les clients API utilisent Authorization: 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 :

HelperSignatureDescription
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_headersIP 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 role admin.
  • auth_service::auth_middleware — auth primaire (extraction JWT + validation + injection Claims en 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.
  • /admin et /api/v1/admin restent en plus protégés indépendamment par AdminService.
  • Une app headless (sans with_ui) ne protège que ce qu'elle déclare via register_protected_prefix("/…").

⚠ Migration au prochain update-project (apps headless uniquement) : si votre app sert des pages sous /home, /profile ou /settings en comptant sur les anciens défauts, ajoutez les register_protected_prefix(..) correspondants — sinon ces pages deviennent publiques. /admin/* reste couvert par AdminService. 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) : 303 vers login_path (défaut /login, env LOGIN_PATH ou builder .login_path("/…")).
  • Json401 : 401 {"error":"unauthorized"}. .headless() bascule le défaut sur Json401.

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, AuthService et AdminService ignoraient ces overrides (ils relisaient AuthConfig::from_env() à chaque requête, cf. RFC 0042) : une app headless sans AUTH_FAILURE_MODE répondait 303 → /login. Depuis 1.2.0 elle répond 401. Si vous composez votre pile tower à la main, utilisez AuthService::with_auth_config(inner, db, prefixes, auth_config) et AdminService::with_auth_config(inner, db, auth_config) ; les constructeurs new sont 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>>,
}
ElementSignature / valeurDescription
SecurityHeadersConfig::default()—CSP = DEFAULT_CONTENT_SECURITY_POLICY, COOP/CORP = None
with_same_origin_isolation(self) → SelfPositionne COOP et CORP a same-origin. La CSP n'est pas touchee.
allowing_script_origin(self, origin: &str) → SelfAjoute origin a script-src et connect-src. Idempotent ; une directive absente est creee depuis default-src. Voir §23.7.
DEFAULT_CONTENT_SECURITY_POLICY&'static strLa CSP historique 1.0.1 (avec 'unsafe-inline' et 'unsafe-eval')
CSP_WITHOUT_UNSAFE_EVAL&'static strCandidat Phase 2, identique au defaut moins 'unsafe-eval'. Non utilise par Default.
RustwardenBuilder::security_headers(cfg: SecurityHeadersConfig) → SelfOverride au builder

ℹ️ Interaction avec .analytics(..). Quand une URL de script de suivi est configuree, build() applique allowing_script_origin a 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-teteConfigurable ?Defaut
Content-Security-PolicyOuiDEFAULT_CONTENT_SECURITY_POLICY
Cross-Origin-Opener-PolicyOui, opt-innon emis (None)
Cross-Origin-Resource-PolicyOui, opt-innon emis (None)
X-Content-Type-OptionsNonnosniff
X-Frame-OptionsNonDENY
X-XSS-ProtectionNon0
Referrer-PolicyNonstrict-origin-when-cross-origin
Permissions-PolicyNoncamera=(), microphone=(), geolocation=()
Strict-Transport-SecurityNonmax-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 Arc a la construction du layer, pas lue depuis une Extension : ce layer est plus externe que les Extension(...), 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

MethodeSignatureDescription
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

FonctionSignatureDescription
sanitize_email_content(content: &str) → StringEchappe les caracteres HTML (&, <, >, ", ').
is_valid_email_format(email: &str) → boolValidation RFC d'email via le crate validator.

default_admin_service

Module : rustwarden_core::services::default_admin_service

FonctionSignatureDescription
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_challenge applique 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_step ajoute user_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.

AspectComportement
Rejeu detecteOk(false) — identique a un code invalide, pas d'oracle de rejeu
Multi-replicasFerme : le CAS SQL est partage, la garde in-process de 1.0.1 ne couvrait qu'un processus
verify_and_enableConsomme le pas avant de basculer enabled = true : le code de setup ne peut pas servir de login immediat
Erreur DBPropagee en AppError — jamais fail-open
Garde in-processConservee apres le CAS, en defense en profondeur
Lignes existanteslast_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 :

VarianteHTTPUsage
AppError::NotFound(msg)404Ressource introuvable
AppError::Validation(msg)400Donnees invalides
AppError::Forbidden403Acces refuse (RBAC, ownership)
AppError::Unauthorized401Non authentifie
AppError::InvalidToken401JWT / PAT / token reset/verify invalide
AppError::TokenExpired401JWT expire
AppError::TokenReused401Refresh token reutilise (attaque detectee)
AppError::TooManyRequests { retry_after_secs }429Rate limit depasse. Header Retry-After: N ajoute automatiquement.
AppError::Conflict(msg)409Unicite violee (username, email)
AppError::Internal(msg)500Erreur interne
AppError::Config(msg)500Configuration manquante

Rate limiting

Toutes les limites passent par RateLimitService avec des cles prefixees (single source of truth, pas de duplication) :

CleLimiteWindow typeExpose via
auth:{ip}env RATE_LIMIT_AUTH_PER_MINUTE (default 10/min)FixedLayer auth_ip_rate_limit sur auth_api_routes() (ConnectInfo). Helper check_auth_rate_limit(headers) = fallback headers-only.
share:{ip}30/minFixedcheck_public_share_rate_limit*
pwd_change:{user_id}3/hour (config)Sliding strictPasswordChangeService::create_pending_request
pwd_reset:{email}3/hourSliding strictPasswordResetService::request_reset
email_verify:{email}3/hourSliding strictEmailValidationService::resend_verification
* (vos cles)libreles deuxRateLimitService::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 — AuthLoggingService l'emet deja pour auth.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), voir fediverse-consumer-guide.md. Cette section couvre uniquement les APIs publiques des services exposés.

Périmètre & crates

CrateFeature appRôle
rustwarden-fediversefediverseProtocole ActivityPub headless (services, routes, signatures). Aucune UI.
rustwarden-fediverse-uifediverse-ui (⇒ fediverse)UI HTML de référence (handlers admin/user, templates Askama, dashboard card).
rustwarden-fediverse-guardfediverse-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éthodeSignatureDescription
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) → &strDomaine canonique de l'instance.
base_url(&self) → &UrlURL de base (https://{domain}/).
user_agent(&self) → &strUA envoyé sur les requêtes sortantes.
authorized_fetch(&self) → boolMode authorized-fetch (récupération signée d'acteurs distants).
outbound_workers(&self) → usizeNombre de workers outbound (réservé).
outbound_max_attempts(&self) → u32Default 8 tentatives avant Dead.
outbound_request_timeout(&self) → DurationDefault 30 s par tentative.
fetch_actor_cache_ttl(&self) → DurationDefault 24 h sur remote_identities.
auth_challenge_ttl(&self) → DurationDefault 5 min sur un challenge AP.
debug_local_federation(&self) → boolNe 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éthodeSignatureDescription
new(db) → SelfConstruit 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éthodeSignatureDescription
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éthodeSignatureDescription
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) → DurationPure function. Expose le ladder de retry pour les tests / l'UI.
OutboundWorker::new(db, transport: T, poll_interval: Duration) → SelfConstruit 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émentationModuleUsage
NoopDeliveryservices::fediverse_outbound_serviceLogue + return Ok. Tests / dev.
HttpSignedDeliveryhttp_signaturesSigne 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éthodeSignatureDescription
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éthodeSignatureDescription
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éthodeSignatureDescription
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 :

ImplModuleUsage
NoopFediverseObserverrustwarden_fediverse::observerZero-cost no-op. Default si la feature fediverse-guard n'est pas activée.
GuardObserverrustwarden_fediverse_guardDé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 :

ImplModuleMode
HttpSigVerifierrustwarden_fediverse::http_signaturesProduction. 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).
AlwaysAcceptVerifierservices::fediverse_signal_serviceTestbed 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>.

FonctionSignatureRoutes 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 sur builder.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éthodeSignatureDescription
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/Team vivent dans resource_shares (core) ; les permissions Remote vivent dans fediverse_resource_shares (fediverse). Le Principal enum reste à 2 variants. La colonne resource_shares.shared_with_remote_identity_id hé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éthodeSignatureDescription
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_identity est l'API qu'un consommateur câble depuis son impl de FediverseEventObserver::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 RemoteIdentityRevocationChecker si vous activez fediverse — sans lui, Delete{Actor} n'invalide rien.
  • Toujours utiliser HttpSigVerifier en prod — AlwaysAcceptVerifier ne doit être atteint qu'avec FEDIVERSE_DEBUG_LOCAL_FEDERATION=true (guard explicite dans main).
  • Toujours tokio::spawn(OutboundWorker::run()) au démarrage, avec un HttpSignedDelivery injecté — NoopDelivery ne fait que logger.
  • Toujours s'abonner au target tracing rustwarden::fediverse::outbound — sinon vous découvrirez les dead par 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 dans fediverse-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.md et chaîne de layout personnalisée.
  • Génère docs/plans/ui-screens.md dans 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

LimiteDétail
Détection textuelleUn .route("/x") commenté est compté comme implémenté.
Fichiers non-RustLes 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 de ADMIN_PASSWORD=password, alerte APP_DEBUG/COOKIE_SECURE), cookies HttpOnly/Secure/SameSite, cargo audit + JWT en HMAC.
  • Conventions de rendu sûr — escaping contextuel (jamais de valeur utilisateur dans un <script> : data-* + dataset), textContent plutô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
}
ElementSignatureDescription
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, ..) → SelfSetters 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) → SelfCâ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_middleware capture sa configuration une seule fois, dans un Arc construit au build() et placé hors de toute couche Extension (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églageSourceModifiable à chaud
URL du scriptANALYTICS_SCRIPT_URL / builderNon (alimente la CSP)
Attribut portant l'identifiantANALYTICS_SITE_ID_ATTRIBUTE / builderNon
Clé d'opposition localStorageANALYTICS_OPT_OUT_STORAGE_KEY / builderNon
deferANALYTICS_DEFER / builderNon
ActivationANALYTICS_ENABLED, puis app_settings.analytics_enabledOui
Identifiant de siteANALYTICS_SITE_ID, puis app_settings.analytics_site_idOui
Attributs data-*ANALYTICS_ATTRIBUTES, puis app_settings.analytics_attributesOui

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.

ElementSignatureDescription
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) → SelfSans 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) → &AnalyticsConfigLa configuration d'environnement, avant surcharge.
AnalyticsContextextracteur 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.
AnalyticsHandleextracteur Axum, infaillibleOption<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

VariableDéfautDescription
ANALYTICS_SCRIPT_URL—URL absolue du script. https:// obligatoire, sauf hôte loopback.
ANALYTICS_ENABLEDfalsetrue / 1 / on activent. Surchargeable en base.
ANALYTICS_SITE_ID—Identifiant de site. Surchargeable en base.
ANALYTICS_SITE_ID_ATTRIBUTEdata-site-idAttribut 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_DEFERtruefalse / 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.

FournisseurANALYTICS_SITE_ID_ATTRIBUTEClé d'opposition usuelle
Rybbitdata-site-id (défaut)disable-rybbit
Umamidata-website-id—
Plausibledata-domainplausible_ignore
Matomo-tagdata-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 |safe ici, alors que §22 proscrit ce filtre. La règle de §22 vise les valeurs utilisateur. Le champ snippet n'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, src et integrity hors de la balise : seuls des noms data-[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 : AnalyticsConfig ne 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).