RFC 0042 — Auth — AuthService / AdminService reçoivent l'AuthConfig résolue à la construction
- Auteur : EBO (origine BotPlace —
docs/UPSTREAM_RUSTWARDEN.md, § « Workarounds / besoins upstream », 2026-10-02) - Date : 2026-10-02
- Statut : Implemented (2026-10-02, train 1.2.0) — D1 tranchée par le mainteneur en faveur de l'Alternative A ; rupture de défaut assumée, consommateurs adaptés manuellement au déploiement. Plan d'exécution local (dossier
docs/plans/, ignoré par git) :2026-10-02-rfc-0042-implementation.md. - Apps consommatrices concernées : BotPlace (bug reproduit par
crates/botplace-ui/tests/prefixes_proteges.rs) ; tout projet généré parrustwarden-cli start-projecten mode headless ;app/examples/minimal_headless.rs; gitrust et rs_pasterman dès qu'ils utilisent.login_path(..)ou.auth_failure_mode(..). - Crates cibles :
rustwarden-core(middleware/auth_service.rs,middleware/admin_service.rs,app.rs) ;rustwarden-ui(handlers/root_handler.rs, correctif compagnon de trois lignes). - Type : correctif structurel + dépréciation (API additive, aucune suppression avant la prochaine major) + un changement de défaut effectif à arbitrer (D1).
- Précédents : RFC 0022 (fallback
from_env()branche protected), RFC 0031 (fallback branche optional). La présente RFC reprend l'idée de l'Alternative C de la RFC 0031 (L248-257, écartée alors parce qu'elle modifiait la signature denew) en la rendant additive par un second constructeur.
Summary
Donner à AuthService et AdminService l'Arc<AuthConfig> résolue une fois par RustwardenBuilder::build() au lieu de la rechercher à chaque requête dans les extensions puis de retomber sur AuthConfig::from_env(), afin que .login_path(), .auth_failure_mode() et la bascule .headless() soient enfin honorés par les middlewares de préfixe.
Motivation
Constat (BotPlace, 2026-10-02 — vérifié sur main 5df0405)
Dans app.rs (L788-811), le ServiceBuilder empile, du plus externe au plus interne (le premier .layer() est le plus externe) :
cors → security_headers → AdminService → AuthService → session → locale → Extension(i18n) → Extension(hooks) → Extension(auth_config) → Extension(db) → Extension(analytics) → router
Trois Router::layer successifs (L820, L828, L831) posent ensuite Extension(revocation_checker), Extension(dashboard_cards) et Extension(oauth_registry) encore plus à l'extérieur que cors — ce qui est l'idiome du dépôt quand une extension doit être visible d'AuthService (commentaire L815-818 : « so AuthService can consult it »). Extension(auth_config) n'a pas reçu ce traitement.
AdminService et AuthService lisent parts.extensions.get::<Arc<AuthConfig>>() (auth_service.rs L239-258 branche protected, L470-484 branche optional ; admin_service.rs L72-90). À cet instant l'extension n'est pas encore posée : ils retombent systématiquement sur AuthConfig::from_env().
Or build() (L513-526) applique les overrides du builder sur sa copie locale avant de la poser en Extension :
let mut auth_config = AuthConfig::from_env()?; if let Some(path) = self.login_path.take() { auth_config.login_path = path; } if let Some(mode) = self.auth_failure_mode { auth_config.auth_failure_mode = mode; } else if self.headless { auth_config.auth_failure_mode = AuthFailureMode::Json401; }
Cette copie n'est jamais vue par les deux middlewares.
Les huit sites AuthConfig::from_env() en chemin de requête
| # | Site | Voit l'extension dans le câblage canonique ? |
|---|---|---|
| 1 | auth_service.rs L245 (branche protected) | Non → from_env() à chaque requête |
| 2 | auth_service.rs L475 (branche optional) | Non → from_env() à chaque requête authentifiée |
| 3 | admin_service.rs L78 | Non → from_env() à chaque requête /admin* |
| 4 | rustwarden-ui/src/handlers/root_handler.rs L48 (GET / avec cookie JWT) | N'essaie même pas : from_env() direct |
| 5 | auth_service.rs L71 (auth_failure_from_extensions) | Oui (appelé depuis les extracteurs) |
| 6 | middleware/admin_helper.rs L59 | Oui |
| 7 | auth/extractors.rs L200 (AuthUser) | Oui |
| 8 | middleware/auth_required.rs L47 | Oui |
Les sites 1 à 4 sont dans le périmètre de cette RFC. Les sites 5 à 8 fonctionnent correctement aujourd'hui (ils s'exécutent après le layer Extension) et restent hors périmètre (voir Non-goals).
Quatre conséquences observables
-
Les setters publics du builder sont inopérants sur les deux points d'application principaux.
.login_path("/auth/signin")et.auth_failure_mode(AuthFailureMode::Redirect)sont documentés, acceptés, puis ignorés parAuthService(préfixes protégés) etAdminService(/admin*,/api/v1/admin*). Le contrat réel devientLOGIN_PATH/AUTH_FAILURE_MODEdans l'environnement — exactement le workaround que BotPlace a dû documenter dans son.env.example. -
La promesse de
.headless()n'est pas tenue. La rustdoc deheadless()(app.rsL370-376) etdocs/SERVICES_REFERENCE.md(L1335) annoncent : « bascule le défaut surJson401». En pratique, un projet headless qui enregistreregister_protected_prefix("/api/v1/private")répond303 See Other → /login?redirect=…vers une page qui n'existe pas, sauf siAUTH_FAILURE_MODE=json401est posé dans l'environnement./api/v1/admin/*(couvert parAdminService) a le même défaut. -
Comportement incohérent dans une même application. Les sites 5 à 8 honorent le builder ; les sites 1 à 3 ne l'honorent pas. Dans la même app headless :
/whoami(extracteurAuthUser, câblé par le scaffold CLI) →401 JSON;/api/v1/private(préfixe protégé) →303 → /login. -
Coût et bruit sur le hot path.
from_env()est rejoué à chaque requête sur préfixe protégé, à chaque requête/admin*, à chaque requête hors préfixe portant un JWT non blacklisté (auth_service.rsL452-463), et surGET /avec cookie pour les appswith_ui: seize lectures d'environnement, allocations, validation du secret, et untracing::info!("Auth configuration loaded…")(config/auth.rsL224-229) par requête. Le chemin «500sifrom_env()échoue » reste atteignable à chaud si l'environnement du processus est modifié après le démarrage — la configuration validée au boot peut dériver silencieusement.
Pourquoi les tests ne l'attrapent pas
tests/middleware_authconfig_fallback.rstestefrom_env()lui-même (idempotence, roundtrip de signature), pas la propagation des overrides.- Les tests du builder (
app.rsL1098headless_sets_flag_without_ui_prefixes) vérifient le flag, pas le comportement HTTP résultant. - Aucun test n'instancie
AuthServiceavec unAuthConfigexplicite : c'est impossible sans muter l'environnement du processus (unsafe { set_var }en édition 2024), ce qui sérialise les tests et interdit la parallélisation.
Historique
Les RFC 0022 puis 0031 ont ajouté puis symétrisé le fallback au lieu de traiter la cause (résolution par extension dépendante de l'ordre des layers). La RFC 0031 listait l'injection par constructeur et l'écartait faute de besoin consommateur et par crainte d'un breaking sur new. BotPlace fournit le besoin ; un second constructeur plus une dépréciation permettent de livrer la partie « injection » en minor.
Conséquences si on ne fait rien
Chaque consommateur headless ou à UI SSR propre redécouvre le piège (gitrust 2026-05-16, BotPlace 2026-10-02). Deux setters publics restent une API documentée mais sans effet sur les middlewares de préfixe. docs/auth-middleware-layering.md présente comme « fallback » ce qui est le chemin nominal.
Urgence : modérée. Le workaround par variables d'environnement existe. Mais l'écart entre l'API publique du builder et le comportement observé entame la crédibilité du framework auprès de ses consommateurs.
Non-goals
- Ne pas changer la sémantique des deux branches d'
AuthService(protected / optional), ni la listeprotected_prefixes, niregister_protected_prefix. - Ne pas retirer les fallbacks
from_env()des sites 5 à 8 (auth_failure_from_extensions,admin_helper,AuthUser,auth_required) : ils fonctionnent aujourd'hui et deviennent de simples défenses ; leur suppression est planifiée avec celle des constructeurs dépréciés (prochaine major). - Ne pas supprimer le layer
Extension(Arc<AuthConfig>)d'app.rs: il reste la source des handlers ; redondance acceptée (voir OQ3). - Ne pas toucher à
AuthConfig::from_env()ni au parsing deAUTH_FAILURE_MODE/LOGIN_PATH. En particulier, ne pas ajouter de champ àAuthConfig: la struct (config/auth.rsL37-75) a tous ses champspubet n'est pas#[non_exhaustive], un champ supplémentaire casserait toute construction par littéral (dont le helper de testauth_service.rsL514-520). - Ne pas supporter l'override par requête de la configuration via
Extension(Arc<AuthConfig>)sur les middlewares de préfixe : la configuration capturée à la construction est autoritaire (aucun site du workspace ne pose une telle extension par requête ; le seul producteur estapp.rsL809, au démarrage). - Ne pas introduire de
tower::Layerdédiés (voir OQ4). - Pas de changement de schéma DB, pas de changement de format sérialisé.
API proposée
AuthService
// crates/rustwarden-core/src/middleware/auth_service.rs #[derive(Clone)] pub struct AuthService<S> { inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>, /// Configuration resolved once by `RustwardenBuilder::build()`. /// `None` only on the deprecated `AuthService::new` path, which keeps /// the historical lookup (request extension, then `AuthConfig::from_env`). auth_config: Option<Arc<AuthConfig>>, } impl<S> AuthService<S> { /// Create the middleware with the authoritative [`AuthConfig`]. /// /// Used by [`crate::app::RustwardenBuilder::build`] since 1.2.0. The /// config is captured once at construction (same pattern as /// `security_headers_middleware`), so builder overrides such as /// [`crate::app::RustwardenBuilder::login_path`] and /// [`crate::app::RustwardenBuilder::auth_failure_mode`] are honoured /// regardless of where `Extension(Arc<AuthConfig>)` sits in the stack. /// /// The captured config takes precedence over any /// `Extension(Arc<AuthConfig>)` found in the request. On every request /// the middleware also inserts it into the request extensions, so /// downstream extractors (`AuthUser`, `auth_required`, `admin_required`) /// observe the very same instance even in hand-composed tower stacks. /// /// ```no_run /// # use std::sync::Arc; /// # use rustwarden_core::config::auth::AuthConfig; /// # use rustwarden_core::middleware::auth_service::AuthService; /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { /// let auth_config = Arc::new(AuthConfig::from_env()?); /// let prefixes = Arc::new(vec!["/api/private".to_string()]); /// let app = router.layer(tower::layer::layer_fn(move |inner| { /// AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone()) /// })); /// # let _ = app; Ok(()) } /// ``` pub fn with_auth_config( inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>, auth_config: Arc<AuthConfig>, ) -> Self { Self { inner, db, protected_prefixes, auth_config: Some(auth_config) } } /// Legacy constructor: resolves `AuthConfig` per request from the /// extensions, then from the environment. #[deprecated( since = "1.2.0", note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; \ the per-request `AuthConfig::from_env()` fallback ignores builder \ overrides (`login_path`, `auth_failure_mode`, `headless`). \ Removal no earlier than 1.5.0, effective in the next major." )] pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self { Self { inner, db, protected_prefixes, auth_config: None } } } /// Authoritative config first; request extension then environment only on /// the deprecated path. A free function so it can be called from inside the /// `'static` boxed future returned by `call` (where `self` is unavailable). fn resolve_auth_config( captured: Option<&Arc<AuthConfig>>, extensions: &axum::http::Extensions, ) -> Result<Arc<AuthConfig>, String> { if let Some(cfg) = captured { return Ok(cfg.clone()); } if let Some(cfg) = extensions.get::<Arc<AuthConfig>>() { return Ok(cfg.clone()); } AuthConfig::from_env().map(Arc::new) }
Dans call (signature à passer en fn call(&mut self, mut req: Request<B>)), avant le Box::pin :
// Seed the request so every downstream extractor sees the same config, // whatever the consumer's layer ordering. In the canonical wiring the // `Extension(Arc<AuthConfig>)` layer later re-inserts the same `Arc` // (a no-op); the seeding only matters for hand-composed stacks. if let Some(cfg) = &self.auth_config { req.extensions_mut().insert(cfg.clone()); } // Cloned here because the boxed future below cannot borrow `self`. let captured = self.auth_config.clone();
Les deux branches remplacent leur bloc match parts.extensions.get… / None => from_env() par resolve_auth_config(captured.as_ref(), &parts.extensions). Le traitement d'erreur (500 + tracing::error! sur protected, traversée sans claims sur optional) est conservé pour le chemin déprécié.
AdminService
// crates/rustwarden-core/src/middleware/admin_service.rs #[derive(Clone)] pub struct AdminService<S> { inner: S, db: DatabaseConnection, auth_config: Option<Arc<AuthConfig>>, } impl<S> AdminService<S> { /// Create the middleware with the authoritative [`AuthConfig`] /// (see `AuthService::with_auth_config` for the rationale). /// /// ```no_run /// # use std::sync::Arc; /// # use rustwarden_core::config::auth::AuthConfig; /// # use rustwarden_core::middleware::admin_service::AdminService; /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { /// let auth_config = Arc::new(AuthConfig::from_env()?); /// let app = router.layer(tower::layer::layer_fn(move |inner| { /// AdminService::with_auth_config(inner, db.clone(), auth_config.clone()) /// })); /// # let _ = app; Ok(()) } /// ``` pub fn with_auth_config(inner: S, db: DatabaseConnection, auth_config: Arc<AuthConfig>) -> Self { Self { inner, db, auth_config: Some(auth_config) } } #[deprecated( since = "1.2.0", note = "use `AdminService::with_auth_config(inner, db, auth_config)`. \ Removal no earlier than 1.5.0, effective in the next major." )] pub fn new(inner: S, db: DatabaseConnection) -> Self { Self { inner, db, auth_config: None } } }
Même fonction libre resolve_auth_config (partagée via pub(crate) depuis auth_service.rs), même seeding, même mut req.
RustwardenBuilder::build() (app.rs)
// 3. Auth config & session let failure_mode_set_in_env = std::env::var_os("AUTH_FAILURE_MODE").is_some(); let auth_config = self.apply_auth_overrides( AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?, failure_mode_set_in_env, ); // Must stay AFTER `apply_auth_overrides`: the session layer reads // `session_timeout_minutes` / cookie settings from the final config. let session_layer = SessionConfig::create_memory_layer(&auth_config); let auth_config = Arc::new(auth_config); let cfg_for_admin = auth_config.clone(); let cfg_for_auth = auth_config.clone(); // ... ServiceBuilder::new() .layer(cors_layer) .layer(/* security_headers_middleware, unchanged */) .layer_fn(move |service| { AdminService::with_auth_config(service, db_for_admin.clone(), cfg_for_admin.clone()) }) .layer_fn(move |service| { AuthService::with_auth_config( service, db_for_auth.clone(), prefixes_for_auth.clone(), cfg_for_auth.clone(), ) }) .layer(session_layer) // ... .layer(axum::Extension(auth_config)) // kept on purpose, see OQ3
Extraction d'une fonction pure (aucune lecture d'environnement ; le signal env est passé en paramètre) pour la précédence des overrides :
impl RustwardenBuilder { /// Apply builder overrides on top of the environment-loaded config. /// /// Precedence (D1): explicit setter > `AUTH_FAILURE_MODE` in the /// environment > `headless()` default flip > `Redirect`. fn apply_auth_overrides(&self, mut cfg: AuthConfig, failure_mode_set_in_env: bool) -> AuthConfig { if let Some(path) = &self.login_path { cfg.login_path = path.clone(); } if let Some(mode) = self.auth_failure_mode { cfg.auth_failure_mode = mode; } else if self.headless && !failure_mode_set_in_env { cfg.auth_failure_mode = AuthFailureMode::Json401; } cfg } }
Correctif compagnon — rustwarden-ui/src/handlers/root_handler.rs (site 4)
// Prefer the config resolved at build time; keep `from_env()` as a defence // for stacks that do not pose the extension. let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() { Some(cfg) => cfg, None => match AuthConfig::from_env() { Ok(cfg) => Arc::new(cfg), Err(_) => { /* unchanged: warn + redirect to login */ } }, };
Trois lignes, aucune API touchée. Nécessaire pour que les énoncés de performance et d'observabilité ci-dessous soient vrais aussi pour les apps with_ui.
Exemple consommateur : pile tower composée à la main (hors builder)
let auth_config = Arc::new(AuthConfig::from_env()?); let app = router.layer( ServiceBuilder::new() .layer_fn({ let (db, cfg) = (db.clone(), auth_config.clone()); move |s| AdminService::with_auth_config(s, db.clone(), cfg.clone()) }) .layer_fn({ let (db, cfg, p) = (db.clone(), auth_config.clone(), prefixes.clone()); move |s| AuthService::with_auth_config(s, db.clone(), p.clone(), cfg.clone()) }) .into_inner(), ); // No need to add Extension(Arc<AuthConfig>) yourself: the middleware seeds it.
À livrer tel quel dans app/examples/custom_auth_stack.rs (processus §8, item 5 : exemple obligatoire pour un pattern middleware).
Reproduction (avant / après)
Diff réel sur app/examples/minimal_headless.rs :
RustwardenApp::builder() .from_env() .headless() .auto_migrate(true) + .register_protected_prefix("/api/status") .merge_machine_routes(api_routes())
unset AUTH_FAILURE_MODE cargo run -p rustwarden-app --example minimal_headless & curl -si http://localhost:3000/api/status | head -2 # Avant : HTTP/1.1 303 See Other ← attendu 401 en headless # location: /login?redirect=%2Fapi%2Fstatus # Après : HTTP/1.1 401 Unauthorized # {"error":"unauthorized"}
Analyse de compatibilité
-
API existante étendue (
with_auth_configsur les deux middlewares,apply_auth_overridesprivé) → non-breaking, minor. -
API existante dépréciée, non supprimée →
#[deprecated]avec note et remplacement. Processus §5 : suppression possible au plus tôt trois minors après la dépréciation (1.2.0 → 1.5.0), effective dans la première major ultérieure ; entréeDeprecatedà reporter dans chaque minor intermédiaire. Appelants denewdans le workspace au 2026-10-02 :app.rsL797 et L799 uniquement ; les templatesrustwarden-clin'appellent pas les constructeurs ; aucun appel dansrustwarden-ui,rustwarden-fediverse*,app/. - API existante modifiée ou supprimée → non. Mais voir la ligne suivante.
-
Nouveau comportement par défaut effectif → oui, et c'est le point à arbitrer (D1). Le processus §1 (principe 1) qualifie « une modification du comportement par défaut » de breaking « même si le code compile », §5 interdit tout breaking en minor, et §6 tranche l'ambiguïté en faveur de « stable » (les codes HTTP du happy path sont listés comme stables). Population exposée : apps
.headless()sans.auth_failure_mode(..)niAUTH_FAILURE_MODEposé → leurs préfixes protégés et/api/v1/admin*passent de303 → /login(bug) à401 JSON(défaut documenté depuis05aeaca,SERVICES_REFERENCE.mdL1335). Pour les appswith_ui(..): rien ne change.- Recensement des consommateurs connus : BotPlace est
.headless()avec UI SSR propre, mais appelle.auth_failure_mode(AuthFailureMode::Redirect)sur le builder et poseAUTH_FAILURE_MODE=redirect(docs/UPSTREAM_RUSTWARDEN.md, § A2 et § Workarounds) → non affectée, quelle que soit la précédence retenue. gitrust et rs_pasterman sont des appswith_ui→ non affectées. Les projets scaffoldés parrustwarden-clien headless n'enregistrent aucun préfixe protégé par défaut et protègentwhoamipar l'extracteurAuthUser(déjàJson401) → seul/api/v1/admin*change de code, dans le sens attendu par un client API. Population résiduelle : apps headless + SSR qui s'appuient sur le303sans setter ni env — aucune connue ; remédiation sans redéploiement de code :AUTH_FAILURE_MODE=redirect. - Précédence retenue (ex-OQ1) : setter > env > bascule
headless>Redirect.headless()documente une bascule de défaut ; une valeur d'environnement explicite n'est pas un défaut. C'est aussi ce qui réduit la population exposée au strict minimum ci-dessus. - D1 — à trancher par le mainteneur : (a) livrer la bascule en 1.2.0 comme restauration d'un défaut documenté, avec entrée CHANGELOG
Changed, rollback par variable d'environnement et patch1.2.1de secours (Alternative A, recommandée) ; ou (b) livrer l'injection en 1.2.0 mais garder la bascule headless derrière un opt-in jusqu'à la prochaine major (Alternative E, strictement non-breaking au sens §1).
- Recensement des consommateurs connus : BotPlace est
-
Projets headless déjà générés par le CLI et déployés : passent par
update-project/.rustwarden-version. À vérifier chez eux avant adoption :grep -rn register_protected_prefix src/et clients de/api/v1/admin*qui attendraient un303. - Extracteurs internes (sites 5 à 8) : inchangés ; ils voient la même instance qu'avant.
- Changement de schéma DB → non.
- Changement de format sérialisé → non.
-
Logs : disparition du
Auth configuration loadedpar requête sur les sites 1 à 4 (format des logs = non-stable, §6). -
Versions : les sept manifestes
Cargo.tomldu workspace sont à1.0.0alors que le CHANGELOG a publié1.0.1et1.1.0(seuls tags git :V0.1.0,v01). La numérotation de cette RFC (1.2.0, hotfix1.1.1) suit le CHANGELOG ; à réaligner au moment du train.
Alternatives considérées
Alternative A (recommandée) : injection par constructeur + seeding + dépréciation + bascule headless effective
Décrite ci-dessus, avec la précédence setter > env > headless.
Pros : supprime structurellement la dépendance à l'ordre des layers pour les sites 1 à 3 ; une seule AuthConfig validée au boot pour tous les points d'application ; retire from_env() du hot path (sites 1 à 4) ; rend AuthService / AdminService testables sans environnement ni base ; pattern déjà adopté par security_headers_middleware (RFC 0039) ; corrige aussi les piles composées à la main ; la promesse de headless() devient vraie partout.
Cons : deux constructeurs à maintenir jusqu'à la major ; ~90 lignes sur quatre fichiers ; un défaut effectif change pour une population résiduelle (D1) ; deux sources de la même instance dans la requête (seeding + layer Extension), inertes l'une par rapport à l'autre dans le câblage canonique.
Alternative B : remonter Extension(auth_config) en tête de pile
Poser .layer(axum::Extension(auth_config.clone())) en Router::layer après le ServiceBuilder, comme revocation_checker (app.rs L815-820).
Pros : trois lignes, aucun changement d'API, livrable en patch 1.1.1 ; débloque BotPlace immédiatement ; c'est l'idiome déjà employé dans le dépôt pour rendre une extension visible d'AuthService.
Cons : la correction dépend toujours de l'ordre des layers (le bug reviendra au prochain réagencement, comme après d4d6828) ; les piles composées à la main par les consommateurs restent cassées ; le fallback from_env() par requête subsiste en code mort défensif ; les tests exigent toujours de muter l'environnement. Écartée par la RFC 0031 pour les mêmes raisons. Compatible avec A comme mesure transitoire (OQ2).
Alternative C : tower::Layer dédiés (AuthLayer, AdminLayer)
Pros : plus idiomatique ; les consommateurs réutilisent le layer sans closure.
Cons : surface d'API supplémentaire à stabiliser ; ne corrige rien de plus que A ; peut être ajouté par-dessus A ultérieurement (OQ4).
Alternative D : ne rien faire (documenter que le contrat réel est l'environnement)
Pros : zéro coût immédiat.
Cons : deux setters publics restent une API sans effet ; la rustdoc de headless() et SERVICES_REFERENCE.md sont fausses ; chaque consommateur headless redécouvre le piège ; coût et log par requête conservés ; impossibilité de tester les overrides sans unsafe { set_var }.
Alternative E : injection en 1.2.0, bascule headless différée derrière un opt-in
Identique à A pour les constructeurs, le seeding et la dépréciation. Mais apply_auth_overrides ne bascule Json401 sur headless() que si le consommateur appelle un nouveau setter .headless_json401(true) (ou active une feature experimental-headless-json401, processus §5 « feature flags » : naissance en minor, promotion après deux apps adoptantes et 30 jours, défaut en major).
Pros : strictement non-breaking au sens §1 ; les setters explicites .login_path() / .auth_failure_mode() sont honorés dès 1.2.0 (c'est le besoin BotPlace) ; la bascule arrive en major avec son guide MIGRATION-V2.md.
Cons : prolonge volontairement pendant tout un cycle l'incohérence entre AuthUser (déjà Json401 en headless) et les middlewares de préfixe ; ajoute un setter ou une feature temporaire à retirer ensuite ; la rustdoc de headless() reste fausse jusqu'à la major sauf à la réécrire deux fois.
Choix proposé : Alternative A, avec B acceptée comme hotfix optionnel (OQ2) et E comme repli si le mainteneur tranche D1 en faveur d'une lecture stricte de §1.
Testing plan
Les tests unitaires ci-dessous tournent sans PostgreSQL (MockDatabase via la dev-dépendance sea-orm/mock déjà déclarée) et n'exigent aucune variable d'environnement. Attention : ils ne peuvent pas garantir l'absence de JWT_SECRET, car plusieurs mod tests du même binaire de lib appellent dotenvy::dotenv().ok() (hooks.rs L381, tasks/pending_email_change_cleanup.rs L100, …) et chargent le .env du dépôt dans le processus. La preuve que with_auth_config ne touche plus from_env() est donc portée par le test #6 ci-dessous, dans un binaire d'intégration dédié. Pattern HTTP : Router + tower::ServiceExt::oneshot, comme les tests de security_headers_middleware dans app.rs.
Mock de la blacklist
JwtBlacklistService::is_blacklisted (services/jwt_blacklist_service.rs L147-162) exécute un count(). Sur la branche protected, Err(_) est traité comme « blacklisté » (auth_service.rs L272-278) : un MockDatabase sans résultat court-circuite donc avant validate_token. Les tests #4 et #5 doivent appendre un résultat de comptage (aucun précédent dans le dépôt, qui ne mocke que des Model) :
use sea_orm::{DatabaseBackend, MockDatabase, Value}; use std::collections::BTreeMap; fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> { BTreeMap::from([("num_items", Value::BigInt(Some(n)))]) } let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![blacklist_count(0)]]) .into_connection();
Unitaires — auth_service.rs
- Happy : préfixe protégé, pas de JWT,
Json401injecté →401+ corps{"error":"unauthorized"}(aucune requête DB émise). - Happy :
Redirect+login_path = "/auth/signin"injectés →303+Location: /auth/signin?redirect=%2Fprivate. - Seeding : route hors préfixe, handler lisant
Extension<Arc<AuthConfig>>, aucun layerExtensiondans la pile de test → lelogin_pathrenvoyé est celui injecté. - Erreur — signature invalide : JWT signé avec un autre secret, blacklist mockée à
0(le flux atteintvalidate_token),Json401→401. Assertion complémentaire : le journal du mock (into_transaction_log()) contient exactement une requêteCOUNT. - Erreur — blacklist : JWT valide (signé avec le secret injecté), blacklist mockée à
1→401. Le token valide distingue ce test du #4 : il prouve que le court-circuit blacklist s'applique à un token qui aurait autrement passé. - Legacy / preuve d'indépendance à l'environnement — dans
crates/rustwarden-core/tests/auth_service_resolved_config.rs(binaire isolé, patternENV_LOCK+remove_var("JWT_SECRET")detests/middleware_authconfig_fallback.rsL19-46) : avecJWT_SECRETretiré,AuthService::with_auth_configrépond401sur un préfixe protégé là oùAuthService::new(#[allow(deprecated)]) répond500; avecExtensionposée en amont,newl'utilise (comportement historique conservé).
Unitaires — admin_service.rs
/adminet/api/v1/admin/xsans JWT,Json401→401;Redirect+login_pathcustom →303vers ce chemin.
Unitaires — app.rs
apply_auth_overrides(fonction pure, parallélisable) :headless()→Json401; setterRedirectprime surheadless();failure_mode_set_in_env = trueprime surheadless();login_pathpropagé ; non-headless sans setter → inchangé.
Unitaires — rustwarden-ui
root_redirectavecExtension(Arc<AuthConfig>)posée et cookie JWT valide → redirige vers/homesans appel àfrom_env()(vérifiable par unlogin_pathnon standard dans la config injectée, impossible à obtenir depuis l'environnement du test).
Intégration
tests/middleware_authconfig_fallback.rsconservé tel quel (il couvrefrom_env()et le chemin déprécié).- Bout en bout, si un harnais DB est disponible en CI :
RustwardenBuilder::default().headless().register_protected_prefix("/p").build()+oneshot→401; et test de garde sur l'ordre des layers : une route dont le handler litExtension<Arc<AuthConfig>>renvoie la config du builder (protège les sites 5 à 8, toujours sensibles à l'ordre). Sinon, couverture consommateur :prefixes_proteges.rs(BotPlace), à relancer sansAUTH_FAILURE_MODE.
Non-régression
cargo build --workspace --examplessans warning (aucun appel ànewne subsiste hors tests).cargo clippy --workspacezéro warning ;cargo docsans warning (vérifier en particulier les liens intra-doc vers les items dépréciés).- Projet généré par
rustwarden-cli start-projecten headless : compile et répond401sur/api/v1/admin/userssans token.
Doctests
- Les exemples
no_rundeAuthService::with_auth_configetAdminService::with_auth_configcompilent.
Benchs (processus §7 : obligatoire, hot path touché)
- Micro-mesure
AuthConfig::from_env()seule (std::time::Instant, 10 000 itérations) etoneshotsur préfixe protégé sans JWT, pile legacy vs pilewith_auth_config, même machine. Livré : test#[ignore]bench_from_env_versus_captured_configdanstests/auth_service_resolved_config.rs(cargo test -p rustwarden-core --test auth_service_resolved_config -- --ignored --nocapture bench). Résultat (debug, 2026-10-02) :from_env()23,7 ms / 10 000 = 2,37 µs par appel ; pile legacy 40,2 µs/req ; pilewith_auth_config37,7 µs/req (−6 %). Aucune dépendance de bench ajoutée.
Observabilité
Auth configuration loadedn'apparaît qu'une fois au démarrage sur une appwith_uiet sur une app headless (sites 1 à 4 couverts).
Migration guide
Section à copier dans CHANGELOG.md au merge ([1.2.0]).
### Fixed - **RFC 0042.** `AuthService` et `AdminService` honorent désormais `RustwardenBuilder::login_path`, `::auth_failure_mode` et la bascule `Json401` de `::headless()`. Les deux middlewares retombaient systématiquement sur `AuthConfig::from_env()` (ordre des layers) et ne lisaient que `LOGIN_PATH` / `AUTH_FAILURE_MODE`. Préfixes protégés, `/admin*` et extracteurs `AuthUser` / `auth_required` partagent enfin la même configuration. `rustwarden-ui::root_redirect` lit aussi la configuration injectée avant de retomber sur l'environnement. ### Changed - Apps `.headless()` sans `.auth_failure_mode(..)` ni `AUTH_FAILURE_MODE` : les préfixes enregistrés via `register_protected_prefix` et `/api/v1/admin*` répondent `401 {"error":"unauthorized"}` au lieu de `303 → /login` (comportement documenté, jusqu'ici non appliqué). Si votre app headless sert une UI SSR, appelez `.auth_failure_mode(AuthFailureMode::Redirect)` ou posez `AUTH_FAILURE_MODE=redirect` (désormais prioritaire sur la bascule `headless`). - `AuthConfig::from_env()` n'est plus exécutée à chaque requête par les middlewares de préfixe ni par `root_redirect` ; dans le câblage canonique, le log `Auth configuration loaded` n'apparaît plus qu'au démarrage. ### Deprecated - `AuthService::new(inner, db, prefixes)` → `AuthService::with_auth_config(inner, db, prefixes, auth_config)`. - `AdminService::new(inner, db)` → `AdminService::with_auth_config(inner, db, auth_config)`. Suppression au plus tôt en 1.5.0, effective dans la prochaine major.
Migration steps
- Bump
rustwarden-coreà1.2.0(et réaligner les manifestes du workspace, aujourd'hui à1.0.0) ;cargo build. Aucune modification requise si vous passez parRustwardenApp::builder(). - Si vous composez vous-même la pile tower : remplacer
newparwith_auth_configet passer l'Arc<AuthConfig>que vous posiez déjà enExtension. - Apps headless : vérifier que le
401sur préfixes protégés et/api/v1/admin*est bien le comportement voulu ; sinon.auth_failure_mode(Redirect)ouAUTH_FAILURE_MODE=redirect. - Les
AUTH_FAILURE_MODE/LOGIN_PATHposés uniquement pour contourner le bug peuvent être retirés — ou conservés : ils restent lus parfrom_env()au démarrage. - Côté consommateur, fermer l'entrée « RFC upstream à ouvrir » de
docs/UPSTREAM_RUSTWARDEN.mden pointant vers cette RFC.
Rollback (processus §10)
Régression constatée sur une app headless + SSR : poser AUTH_FAILURE_MODE=redirect (sans redéploiement de code). Si la précédence env s'avérait insuffisante : patch 1.2.1 rétablissant Redirect comme défaut headless sur les middlewares, les constructeurs with_auth_config restant en place.
Documentation à mettre à jour dans le même PR
docs/auth-middleware-layering.md: le fallbackfrom_env()devient le chemin legacy ; décrire le seeding,with_auth_configet la précédence setter > env > headless.docs/SERVICES_REFERENCE.mdL1333-1335 : précédence réelle des overrides..env.sample(racine) ettemplate/.env.sample: documenterAUTH_FAILURE_MODEetLOGIN_PATH, aujourd'hui absents (seulJWT_SECRETy figure).- Rustdoc de
RustwardenBuilder::headless,login_path,auth_failure_mode: énoncer la précédence et les points d'application couverts. app/examples/custom_auth_stack.rs: la pile composée à la main ci-dessus.
Open questions
-
OQ1 — Précédence environnement vs bascule
headless: tranchée (setter > env > headless >Redirect), implémentée par le paramètrefailure_mode_set_in_envdeapply_auth_overrides(fonction pure, six tests unitaires). -
D1 — Train de livraison de la bascule headless : Alternative A en 1.2.0 (décision mainteneur du 2026-10-02). La rupture de défaut est assumée et documentée dans le CHANGELOG (section
⚠️ BREAKING) ; les consommateurs sont adaptés manuellement au déploiement. -
OQ2 — Hotfix B en
1.1.1: sans objet, A livrée directement. -
OQ3 — Layer
Extension(Arc<AuthConfig>)d'app.rs: conservé (redondance sans coût) ; retrait avec les constructeurs dépréciés. -
OQ4 —
tower::Layerdédiés (AuthLayer,AdminLayer) : RFC séparée si un consommateur en exprime le besoin. -
OQ5 — Signal sur le chemin déprécié : implémenté —
tracing::warn!une fois par processus (std::sync::Once) dansresolve_auth_configquand le cheminfrom_env()est pris.
Écarts d'implémentation par rapport à l'API proposée
build()résout l'AuthConfigavant la connexion à la base (étape 0) et non à l'étape 3 : la fonctionapply_auth_overrides(&self, ..)emprunte le builder alors queself.dbest déplacé à l'étape 1. Aucun effet fonctionnel ;SessionConfig::create_memory_layerreste appelée après les overrides.build(mut self)devientbuild(self).rustwarden-ui::root_redirectredirige en plus versauth_config.login_pathau lieu de/logincodé en dur (cohérent avec l'intention de la RFC :.login_path()honoré partout). Trois tests unitaires dansrustwarden-ui.- Test #11 (bout en bout via
RustwardenBuilder::build()+oneshot) : non écrit, aucun harnais de ce type n'existe dans le dépôt (il faudrait base, migrations et admin par défaut). Couvert par les tests unitaires du builder (#8), le binaire d'intégration isolé (#6) et le test consommateur BotPlaceprefixes_proteges.rsà relancer sansAUTH_FAILURE_MODE. - Signal environnement durci (revue finale) :
failure_mode_set_in_envn'est vrai que pour une valeurAUTH_FAILURE_MODEvalide (redirect/json401).from_env()retombe silencieusement surRedirectquand la valeur est vide ou mal orthographiée ; sans ce durcissement, une faute de frappe dans l'environnement aurait réintroduit le303sur une app headless. Fonction pureRustwardenBuilder::failure_mode_overridden_by_env(raw)testée (None,"","json-401","json401","Redirect"). - Le
tracing::warn!du chemin déprécié est émis après unfrom_env()réussi, pour ne jamais précéder unfrom_env failed. build()échoue désormais sur unJWT_SECRETinvalide avant la connexion DB, les migrations et l'admin par défaut (fail-fast ; consigné au CHANGELOG).- Les deux doctests
no_runcompilent ; le test d'indépendance à l'environnement vit danscrates/rustwarden-core/tests/auth_service_resolved_config.rsavec quatre scénarios (canonique sans env →401/303custom ; legacy sans env →500; legacy +Extensionexterne → honorée ; legacy + env →from_env()et overrides ignorés) plus le bench#[ignore].
Suites à donner (hors périmètre, relevées par la revue finale)
- Fail-open blacklist dans
AdminService(admin_service.rs,is_blacklisted(..).is_ok_and(|b| b)) : une erreur DB laisse passer la requête admin, alors queAuthServiceest fail-closed (Ok(true) | Err(_) => reject). Préexistant, chemin le plus privilégié : ticket dédié. auth_failure_from_extensions(site 5) garde sa chaîne extension →from_env()sans lewarn!Once: la promesse d'observabilité OQ5 ne couvre que les deux middlewares.- Cinq copies du littéral
AuthConfigà quinze champs dans les tests (dont deux horsrustwarden-core). Proposition :#[doc(hidden)] pub fn for_tests()derrière une featuretest-util, à faire avant tout ajout de champ àAuthConfig. - Le bench tourne sans souscripteur
tracing: les 2,37 µs par appel sous-estiment le coût réel duinfo!par requête en production.
Tracking issue
À ouvrir sur le dépôt Rustwarden avec les labels bug, area:auth, rfc, consumer:botplace ; lien à reporter ici avant tout passage en Accepted. Origine : docs/UPSTREAM_RUSTWARDEN.md § « Workarounds / besoins upstream » (2026-10-02). Test consommateur de référence : crates/botplace-ui/tests/prefixes_proteges.rs (BotPlace), à relancer sans AUTH_FAILURE_MODE après adoption.
Score d'évaluation (auto-évaluation proposée, à confirmer par les reviewers)
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 5 | Tout consommateur headless ou à chemin de login custom ; projets CLI ; huit sites recensés, quatre corrigés, quatre rendus cohérents |
| Stabilité API | 3 | Ajout pur pour les constructeurs ; changement de défaut effectif sur une population résiduelle, atténué par la précédence env et un rollback sans code (D1). 5 avec l'Alternative E |
| Performance | 4 | Retire un from_env() par requête sur les sites 1 à 4. Bench #16 (debug, N = 10 000, 2026-10-02) : from_env() seul 2,37 µs/appel ; pile legacy 40,2 µs/req ; pile with_auth_config 37,7 µs/req, soit −6 % par requête protégée sans JWT, plus la disparition du tracing::info! par requête. Gain mesurable mais modeste |
| Sécurité | 4 | Une seule configuration validée au boot ; plus de dérive via l'environnement à chaud ; chemin 500 fermé sur le câblage canonique. Changement de défaut headless à annoncer |
| Complexité d'implémentation | 4 | ~90 lignes sur quatre fichiers + tests ; pattern déjà en place (security_headers_middleware) |
| Charge de maintenance | 5 | Zéro dépendance ; supprime du code (fallbacks) à terme |
| Documentation | 4 | Rustdoc, deux doctests, CHANGELOG, exemple, quatre documents à réviser, .env.sample à compléter |
| Total | 29/35 | ≥ 25 (31/35 avec l'Alternative E) |
Historique :
- 2026-10-02 : Draft. Origine BotPlace (
docs/UPSTREAM_RUSTWARDEN.md). Câblage vérifié surmain5df0405:app.rsL788-831 et L513-526,auth_service.rsL239-258 / L452-484,admin_service.rsL72-90,admin_helper.rsL59,extractors.rsL194-200,auth_required.rsL43-47,config/auth.rsL212-229,rustwarden-ui/handlers/root_handler.rsL48. - 2026-10-02 : D1 tranchée (Alternative A, 1.2.0, rupture assumée) ; implémentation livrée le jour même :
auth_service.rs,admin_service.rs(7 tests),app.rs(apply_auth_overrides, 6 tests),rustwarden-ui/root_handler.rs(3 tests),tests/auth_service_resolved_config.rs(5 tests + bench),app/examples/custom_auth_stack.rs, CHANGELOG 1.2.0, manifestes 1.2.0,auth-middleware-layering.md,SERVICES_REFERENCE.md,.env.sample,template/.env.sample. Statut → Implemented. - 2026-10-02 : revue critique interne (REQUEST CHANGES) intégrée → Under Review. Changements : fonction libre
resolve_auth_config(le&selfétait inaccessible dans le future'static),mut req,apply_auth_overridespure avec signal env en paramètre, site 4 (root_redirect) ajouté au périmètre, recensement des huit sites, mockscount()explicites, test d'indépendance à l'environnement en binaire isolé, bench ajouté, Alternative E, checklist breaking restaurée et décision D1 formalisée, fenêtre de dépréciation alignée sur §5,.env.sampleau lieu de.env.example, désalignement des manifestes signalé.
GitRust