Set retour pour erreur jwt headless avec redirect ou json401
EBO <eric@ebii.fr> committé le 2026-10-02 21:42
45f9ed39cff2c27d2faea81e69e0a4aad7aba561
1 parent(s)
20 fichiers modifiés
+2123
-833
M
CHANGELOG.md
+73
-0
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| 7 | 7 | |
| 8 | 8 | ## [Unreleased] |
| 9 | 9 | |
| 10 | +_Rien pour l'instant._ | |
| 11 | + | |
| 12 | +## [1.2.0] - 2026-10-02 — Breaking (RFC 0042) + Security (RFC 0039 phase 2) | |
| 13 | + | |
| 14 | +Minor assumé **breaking** sur un comportement par défaut (décision D1 du | |
| 15 | +2026-10-02, [`docs/rfcs/0042-auth-middleware-resolved-auth-config.md`](docs/rfcs/0042-auth-middleware-resolved-auth-config.md)) : | |
| 16 | +les consommateurs sont adaptés **manuellement au déploiement**. Les sept | |
| 17 | +manifestes `Cargo.toml` du workspace passent à `1.2.0` (ils étaient restés à | |
| 18 | +`1.0.0` malgré les trains 1.0.1 et 1.1.0). | |
| 19 | + | |
| 20 | +### ⚠️ BREAKING — RFC 0042 : les overrides du builder atteignent enfin les middlewares de préfixe | |
| 21 | + | |
| 22 | +`AuthService` (préfixes enregistrés via `register_protected_prefix`) et | |
| 23 | +`AdminService` (`/admin*`, `/api/v1/admin*`) étaient posés **avant** le layer | |
| 24 | +`Extension(Arc<AuthConfig>)` dans `RustwardenBuilder::build()` ; ils retombaient | |
| 25 | +donc à chaque requête sur `AuthConfig::from_env()` et ignoraient | |
| 26 | +`.login_path(..)`, `.auth_failure_mode(..)` et la bascule `Json401` de | |
| 27 | +`.headless()`. Ils reçoivent désormais la configuration résolue à la | |
| 28 | +construction. | |
| 29 | + | |
| 30 | +**Ce qui change pour vous :** | |
| 31 | + | |
| 32 | +| Votre câblage | Avant 1.2.0 (bug) | Depuis 1.2.0 | | |
| 33 | +|---|---|---| | |
| 34 | +| `with_ui(..)` | `303 → /login` | inchangé | | |
| 35 | +| `.headless()` + `.auth_failure_mode(Redirect)` (ex. BotPlace) | `303` (par l'env uniquement) | `303` (par le builder, comme documenté) | | |
| 36 | +| `.headless()` + `AUTH_FAILURE_MODE=redirect` | `303` | `303` (l'env prime désormais sur la bascule `headless`) | | |
| 37 | +| `.headless()` sans setter ni env | **`303 → /login`** vers une page inexistante | **`401 {"error":"unauthorized"}`** sur préfixes protégés et `/api/v1/admin*` | | |
| 38 | +| `.login_path("/x")` | ignoré par les middlewares | honoré partout | | |
| 39 | + | |
| 40 | +**Précédence** (nouvelle, explicite) : `.auth_failure_mode(..)` > `AUTH_FAILURE_MODE` > | |
| 41 | +bascule `.headless()` > `Redirect`. | |
| 42 | + | |
| 43 | +**Intervention consommateur au déploiement** : si votre app `.headless()` sert une | |
| 44 | +UI SSR et comptait sur le `303`, ajoutez `.auth_failure_mode(AuthFailureMode::Redirect)` | |
| 45 | +au builder (ou `AUTH_FAILURE_MODE=redirect` dans l'environnement — rollback sans | |
| 46 | +redéploiement de code). Si vous composez votre pile tower à la main, passez à | |
| 47 | +`AuthService::with_auth_config` / `AdminService::with_auth_config`. | |
| 48 | + | |
| 49 | +### Fixed | |
| 50 | + | |
| 51 | +- **RFC 0042.** `AuthService` et `AdminService` honorent `RustwardenBuilder::login_path`, | |
| 52 | + `::auth_failure_mode` et la bascule `Json401` de `::headless()`. Préfixes protégés, | |
| 53 | + `/admin*` et extracteurs `AuthUser` / `auth_required` partagent la même | |
| 54 | + configuration : les middlewares la posent eux-mêmes dans les extensions de la | |
| 55 | + requête, ce qui corrige aussi les piles tower composées à la main. | |
| 56 | +- `rustwarden-ui::root_redirect` (`GET /`) lit la configuration injectée avant de | |
| 57 | + retomber sur l'environnement, et redirige vers `login_path` au lieu de `/login` | |
| 58 | + codé en dur. | |
| 59 | + | |
| 60 | +### Changed | |
| 61 | + | |
| 62 | +- `AuthConfig::from_env()` n'est plus exécutée à chaque requête par les middlewares | |
| 63 | + de préfixe ni par `root_redirect` ; le log `Auth configuration loaded` n'apparaît | |
| 64 | + plus qu'au démarrage dans le câblage canonique. | |
| 65 | +- Variables `AUTH_FAILURE_MODE` et `LOGIN_PATH` documentées dans `.env.sample` et | |
| 66 | + `template/.env.sample`. | |
| 67 | +- `RustwardenBuilder::build()` résout l'`AuthConfig` **avant** la connexion à la | |
| 68 | + base, les migrations et la création de l'admin par défaut : un `JWT_SECRET` absent | |
| 69 | + ou placeholder fait échouer `build()` immédiatement (fail-fast), là où ces trois | |
| 70 | + étapes s'exécutaient auparavant d'abord. L'ordre des logs de démarrage change en | |
| 71 | + conséquence. | |
| 72 | +- Une valeur `AUTH_FAILURE_MODE` vide ou invalide est ignorée (avertissement au | |
| 73 | + démarrage) et **ne supprime pas** la bascule `Json401` de `.headless()` : seule une | |
| 74 | + valeur valide (`redirect` / `json401`) compte comme override opérateur. | |
| 75 | + | |
| 76 | +### Deprecated | |
| 77 | + | |
| 78 | +- `AuthService::new(inner, db, prefixes)` → `AuthService::with_auth_config(inner, db, prefixes, auth_config)`. | |
| 79 | +- `AdminService::new(inner, db)` → `AdminService::with_auth_config(inner, db, auth_config)`. | |
| 80 | + Le chemin déprécié émet un `tracing::warn!` une fois par processus. Suppression au | |
| 81 | + plus tôt en 1.5.0, effective dans la prochaine major. | |
| 82 | + | |
| 10 | 83 | ### Security |
| 11 | 84 | |
| 12 | 85 | - **RFC 0039 phase 2.** `SecurityHeadersConfig::default` n'émet plus |
M
Cargo.lock
+7
-7
@@ -4215,7 +4215,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
| 4215 | 4215 | |
| 4216 | 4216 | [[package]] |
| 4217 | 4217 | name = "rustwarden-app" |
| 4218 | -version = "1.0.0" | |
| 4218 | +version = "1.2.0" | |
| 4219 | 4219 | dependencies = [ |
| 4220 | 4220 | "anyhow", |
| 4221 | 4221 | "async-trait", |
@@ -4237,7 +4237,7 @@ dependencies = [
| 4237 | 4237 | |
| 4238 | 4238 | [[package]] |
| 4239 | 4239 | name = "rustwarden-cli" |
| 4240 | -version = "1.0.0" | |
| 4240 | +version = "1.2.0" | |
| 4241 | 4241 | dependencies = [ |
| 4242 | 4242 | "chrono", |
| 4243 | 4243 | "clap", |
@@ -4248,7 +4248,7 @@ dependencies = [
| 4248 | 4248 | |
| 4249 | 4249 | [[package]] |
| 4250 | 4250 | name = "rustwarden-core" |
| 4251 | -version = "1.0.0" | |
| 4251 | +version = "1.2.0" | |
| 4252 | 4252 | dependencies = [ |
| 4253 | 4253 | "aes-gcm", |
| 4254 | 4254 | "anyhow", |
@@ -4301,7 +4301,7 @@ dependencies = [
| 4301 | 4301 | |
| 4302 | 4302 | [[package]] |
| 4303 | 4303 | name = "rustwarden-fediverse" |
| 4304 | -version = "1.0.0" | |
| 4304 | +version = "1.2.0" | |
| 4305 | 4305 | dependencies = [ |
| 4306 | 4306 | "activitypub_federation", |
| 4307 | 4307 | "ammonia", |
@@ -4337,7 +4337,7 @@ dependencies = [
| 4337 | 4337 | |
| 4338 | 4338 | [[package]] |
| 4339 | 4339 | name = "rustwarden-fediverse-guard" |
| 4340 | -version = "1.0.0" | |
| 4340 | +version = "1.2.0" | |
| 4341 | 4341 | dependencies = [ |
| 4342 | 4342 | "askama", |
| 4343 | 4343 | "async-trait", |
@@ -4359,7 +4359,7 @@ dependencies = [
| 4359 | 4359 | |
| 4360 | 4360 | [[package]] |
| 4361 | 4361 | name = "rustwarden-fediverse-ui" |
| 4362 | -version = "1.0.0" | |
| 4362 | +version = "1.2.0" | |
| 4363 | 4363 | dependencies = [ |
| 4364 | 4364 | "askama", |
| 4365 | 4365 | "async-trait", |
@@ -4385,7 +4385,7 @@ dependencies = [
| 4385 | 4385 | |
| 4386 | 4386 | [[package]] |
| 4387 | 4387 | name = "rustwarden-ui" |
| 4388 | -version = "1.0.0" | |
| 4388 | +version = "1.2.0" | |
| 4389 | 4389 | dependencies = [ |
| 4390 | 4390 | "askama", |
| 4391 | 4391 | "async-trait", |
M
app/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-app" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Reference application using the Rustwarden framework" |
A
app/examples/custom_auth_stack.rs
+88
-0
@@ -0,0 +1,88 @@
| 1 | +//! Hand-composed tower stack (RFC 0042) | |
| 2 | +//! | |
| 3 | +//! Shows how to wire `AuthService` / `AdminService` yourself, outside | |
| 4 | +//! `RustwardenApp::builder()`, while still honouring a resolved | |
| 5 | +//! `AuthConfig` (custom `login_path`, explicit `auth_failure_mode`). | |
| 6 | +//! The middlewares seed `Extension(Arc<AuthConfig>)` into every request, | |
| 7 | +//! so no `Extension` layer is needed for the extractors (`AuthUser`, | |
| 8 | +//! `auth_required`, `admin_required`) to see the same instance. | |
| 9 | +//! | |
| 10 | +//! ```bash | |
| 11 | +//! DATABASE_URL=postgres://... JWT_SECRET=... cargo run -p rustwarden-app --example custom_auth_stack | |
| 12 | +//! curl -si http://localhost:3000/api/private/whoami | head -1 # → HTTP/1.1 401 Unauthorized | |
| 13 | +//! curl -si http://localhost:3000/api/v1/admin/ping | head -1 # → HTTP/1.1 401 Unauthorized | |
| 14 | +//! ``` | |
| 15 | + | |
| 16 | +use std::sync::Arc; | |
| 17 | + | |
| 18 | +use axum::{Json, Router, routing::get}; | |
| 19 | +use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode}; | |
| 20 | +use rustwarden_core::config::database; | |
| 21 | +use rustwarden_core::middleware::admin_service::AdminService; | |
| 22 | +use rustwarden_core::middleware::auth_service::AuthService; | |
| 23 | +use sea_orm::DatabaseConnection; | |
| 24 | +use serde_json::json; | |
| 25 | +use tower::ServiceBuilder; | |
| 26 | + | |
| 27 | +#[tokio::main] | |
| 28 | +async fn main() -> anyhow::Result<()> { | |
| 29 | + tracing_subscriber::fmt::init(); | |
| 30 | + | |
| 31 | + let db: DatabaseConnection = database::create_connection().await?; | |
| 32 | + | |
| 33 | + // Resolve the configuration once; apply your own overrides here. | |
| 34 | + let mut auth_config = AuthConfig::from_env().map_err(|e| anyhow::anyhow!(e))?; | |
| 35 | + auth_config.auth_failure_mode = AuthFailureMode::Json401; | |
| 36 | + auth_config.login_path = "/auth/signin".to_string(); | |
| 37 | + let auth_config = Arc::new(auth_config); | |
| 38 | + | |
| 39 | + let protected_prefixes = Arc::new(vec!["/api/private".to_string()]); | |
| 40 | + | |
| 41 | + let app = Router::new() | |
| 42 | + .route("/api/status", get(status)) | |
| 43 | + .route("/api/private/whoami", get(whoami)) | |
| 44 | + .route("/api/v1/admin/ping", get(admin_ping)) | |
| 45 | + .layer( | |
| 46 | + ServiceBuilder::new() | |
| 47 | + .layer_fn({ | |
| 48 | + let (db, cfg) = (db.clone(), auth_config.clone()); | |
| 49 | + move |inner| AdminService::with_auth_config(inner, db.clone(), cfg.clone()) | |
| 50 | + }) | |
| 51 | + .layer_fn({ | |
| 52 | + let (db, cfg, prefixes) = | |
| 53 | + (db.clone(), auth_config.clone(), protected_prefixes.clone()); | |
| 54 | + move |inner| { | |
| 55 | + AuthService::with_auth_config( | |
| 56 | + inner, | |
| 57 | + db.clone(), | |
| 58 | + prefixes.clone(), | |
| 59 | + cfg.clone(), | |
| 60 | + ) | |
| 61 | + } | |
| 62 | + }) | |
| 63 | + .into_inner(), | |
| 64 | + ); | |
| 65 | + | |
| 66 | + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; | |
| 67 | + tracing::info!("custom_auth_stack listening on http://0.0.0.0:3000"); | |
| 68 | + axum::serve(listener, app).await?; | |
| 69 | + Ok(()) | |
| 70 | +} | |
| 71 | + | |
| 72 | +async fn status() -> Json<serde_json::Value> { | |
| 73 | + Json(json!({ "status": "ok", "stack": "custom" })) | |
| 74 | +} | |
| 75 | + | |
| 76 | +/// Reached only with a valid JWT carrying the `admin` role: `/api/v1/admin*` | |
| 77 | +/// is enforced by `AdminService` (anonymous → `401`, non-admin → `403`). | |
| 78 | +async fn admin_ping() -> Json<serde_json::Value> { | |
| 79 | + Json(json!({ "admin": "pong" })) | |
| 80 | +} | |
| 81 | + | |
| 82 | +/// Reached only with a valid JWT: `/api/private` is a protected prefix, so | |
| 83 | +/// anonymous requests get `401 {"error":"unauthorized"}` from `AuthService`. | |
| 84 | +async fn whoami( | |
| 85 | + axum::Extension(claims): axum::Extension<rustwarden_core::auth::jwt::Claims>, | |
| 86 | +) -> Json<serde_json::Value> { | |
| 87 | + Json(json!({ "sub": claims.sub, "username": claims.username })) | |
| 88 | +} |
M
crates/rustwarden-core/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-core" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Core library for Rustwarden – authentication, RBAC, user management framework" |
M
crates/rustwarden-core/src/app.rs
+186
-23
@@ -369,11 +369,17 @@ impl RustwardenBuilder {
| 369 | 369 | |
| 370 | 370 | /// Run in headless mode (API-only, no UI). |
| 371 | 371 | /// |
| 372 | - /// Besides dropping the default UI routes, this flips the default | |
| 372 | + /// Besides dropping the default UI routes, this flips the *default* | |
| 373 | 373 | /// authentication-failure behaviour to [`AuthFailureMode::Json401`] so |
| 374 | - /// protected routes answer unauthenticated requests with `401` JSON | |
| 375 | - /// instead of redirecting to a `/login` page that does not exist. Call | |
| 376 | - /// [`RustwardenBuilder::auth_failure_mode`] afterwards to override. | |
| 374 | + /// protected prefixes, `/admin*` and `/api/v1/admin*` answer | |
| 375 | + /// unauthenticated requests with `401` JSON instead of redirecting to a | |
| 376 | + /// `/login` page that does not exist. | |
| 377 | + /// | |
| 378 | + /// It is only a default: an explicit | |
| 379 | + /// [`RustwardenBuilder::auth_failure_mode`] call wins, and so does an | |
| 380 | + /// explicit `AUTH_FAILURE_MODE` environment variable (RFC 0042). Since | |
| 381 | + /// 1.2.0 the flip is applied by every enforcement point, including the | |
| 382 | + /// prefix middlewares `AuthService` / `AdminService`. | |
| 377 | 383 | /// |
| 378 | 384 | /// [`AuthFailureMode::Json401`]: crate::config::auth::AuthFailureMode::Json401 |
| 379 | 385 | pub fn headless(mut self) -> Self { |
@@ -384,6 +390,12 @@ impl RustwardenBuilder {
| 384 | 390 | |
| 385 | 391 | /// Override the browser login redirect path |
| 386 | 392 | /// ([`crate::config::auth::AuthConfig::login_path`], default `/login`). |
| 393 | + /// | |
| 394 | + /// Precedence: this setter, then the `LOGIN_PATH` env value, then `/login`. | |
| 395 | + /// Honoured by the | |
| 396 | + /// prefix middlewares (`AuthService`, `AdminService`), the extractors | |
| 397 | + /// (`AuthUser`, `auth_required`, `admin_required`) and the UI root | |
| 398 | + /// redirect (RFC 0042). | |
| 387 | 399 | pub fn login_path(mut self, path: impl Into<String>) -> Self { |
| 388 | 400 | self.login_path = Some(path.into()); |
| 389 | 401 | self |
@@ -391,8 +403,12 @@ impl RustwardenBuilder {
| 391 | 403 | |
| 392 | 404 | /// Override how the auth middleware answers unauthenticated requests to |
| 393 | 405 | /// protected routes |
| 394 | - /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]). Takes | |
| 395 | - /// precedence over the default flipped by [`RustwardenBuilder::headless`]. | |
| 406 | + /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]). | |
| 407 | + /// | |
| 408 | + /// Precedence: this setter, then the `AUTH_FAILURE_MODE` env value, then | |
| 409 | + /// the default flipped by [`RustwardenBuilder::headless`], then `Redirect`. | |
| 410 | + /// Honoured by | |
| 411 | + /// every enforcement point since 1.2.0 (RFC 0042). | |
| 396 | 412 | pub fn auth_failure_mode(mut self, mode: crate::config::auth::AuthFailureMode) -> Self { |
| 397 | 413 | self.auth_failure_mode = Some(mode); |
| 398 | 414 | self |
@@ -474,11 +490,62 @@ impl RustwardenBuilder {
| 474 | 490 | self |
| 475 | 491 | } |
| 476 | 492 | |
| 493 | + /// Apply the builder overrides on top of the environment-loaded config. | |
| 494 | + /// | |
| 495 | + /// Pure: the environment is never read here. `failure_mode_set_in_env` | |
| 496 | + /// tells whether `AUTH_FAILURE_MODE` was explicitly set, so an operator | |
| 497 | + /// value keeps precedence over the `headless()` default flip (RFC 0042). | |
| 498 | + /// | |
| 499 | + /// Precedence for `auth_failure_mode`: the `auth_failure_mode()` setter, | |
| 500 | + /// then the `AUTH_FAILURE_MODE` env value, then the `headless()` flip to | |
| 501 | + /// `Json401`, then `Redirect`. | |
| 502 | + /// Precedence for `login_path`: the `login_path()` setter, then the | |
| 503 | + /// `LOGIN_PATH` env value, then `/login`. | |
| 504 | + /// Whether a *valid* `AUTH_FAILURE_MODE` value is present in the | |
| 505 | + /// environment. Pure: takes the raw value so it can be unit-tested. | |
| 506 | + /// | |
| 507 | + /// Presence alone is not enough: `AuthConfig::from_env` silently falls | |
| 508 | + /// back to `Redirect` when the value does not parse (empty string, typo), | |
| 509 | + /// and that fallback must not suppress the `headless()` flip. | |
| 510 | + fn failure_mode_overridden_by_env(raw: Option<&str>) -> bool { | |
| 511 | + raw.and_then(|value| value.parse::<crate::config::auth::AuthFailureMode>().ok()) | |
| 512 | + .is_some() | |
| 513 | + } | |
| 514 | + | |
| 515 | + fn apply_auth_overrides( | |
| 516 | + &self, | |
| 517 | + mut cfg: AuthConfig, | |
| 518 | + failure_mode_set_in_env: bool, | |
| 519 | + ) -> AuthConfig { | |
| 520 | + if let Some(path) = &self.login_path { | |
| 521 | + cfg.login_path = path.clone(); | |
| 522 | + } | |
| 523 | + if let Some(mode) = self.auth_failure_mode { | |
| 524 | + cfg.auth_failure_mode = mode; | |
| 525 | + } else if self.headless && !failure_mode_set_in_env { | |
| 526 | + cfg.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401; | |
| 527 | + } | |
| 528 | + cfg | |
| 529 | + } | |
| 530 | + | |
| 477 | 531 | /// Build the application. Connects to the database, initializes services, |
| 478 | 532 | /// and assembles the router with all middleware. |
| 479 | - pub async fn build(mut self) -> anyhow::Result<RustwardenApp> { | |
| 533 | + pub async fn build(self) -> anyhow::Result<RustwardenApp> { | |
| 480 | 534 | tracing::info!("Building Rustwarden application..."); |
| 481 | 535 | |
| 536 | + // 0. Auth config (SEC-M1: load once, share via Extension; RFC 0042: | |
| 537 | + // the resolved config is also handed to `AuthService` / | |
| 538 | + // `AdminService` at construction so builder overrides reach them). | |
| 539 | + // Resolved before the database is taken out of `self`, as the | |
| 540 | + // overrides live on the builder. | |
| 541 | + let failure_mode_set_in_env = Self::failure_mode_overridden_by_env( | |
| 542 | + std::env::var("AUTH_FAILURE_MODE").ok().as_deref(), | |
| 543 | + ); | |
| 544 | + let auth_config = self.apply_auth_overrides( | |
| 545 | + AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?, | |
| 546 | + failure_mode_set_in_env, | |
| 547 | + ); | |
| 548 | + | |
| 482 | 549 | // 1. Database connection |
| 483 | 550 | let db: DatabaseConnection = if let Some(db) = self.db { |
| 484 | 551 | db |
@@ -509,22 +576,12 @@ impl RustwardenBuilder {
| 509 | 576 | AppSettingsService::initialize_default_settings(&db).await?; |
| 510 | 577 | tracing::info!("Default admin and app settings initialized"); |
| 511 | 578 | |
| 512 | - // 3. Auth config & session (SEC-M1: load once, share via Extension) | |
| 513 | - let mut auth_config = | |
| 514 | - AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?; | |
| 515 | - // Apply builder overrides: an explicit setter wins; otherwise | |
| 516 | - // `.headless()` flips the default failure mode to Json401 so API-only | |
| 517 | - // apps return 401 instead of redirecting to a non-existent /login. | |
| 518 | - if let Some(path) = self.login_path.take() { | |
| 519 | - auth_config.login_path = path; | |
| 520 | - } | |
| 521 | - if let Some(mode) = self.auth_failure_mode { | |
| 522 | - auth_config.auth_failure_mode = mode; | |
| 523 | - } else if self.headless { | |
| 524 | - auth_config.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401; | |
| 525 | - } | |
| 579 | + // 3. Session layer — built from the final config (after overrides, | |
| 580 | + // step 0): it reads the cookie / timeout settings. | |
| 526 | 581 | let session_layer = SessionConfig::create_memory_layer(&auth_config); |
| 527 | 582 | let auth_config = Arc::new(auth_config); |
| 583 | + let auth_config_for_admin = auth_config.clone(); | |
| 584 | + let auth_config_for_auth = auth_config.clone(); | |
| 528 | 585 | tracing::info!("Session management configured"); |
| 529 | 586 | |
| 530 | 587 | // 4. Email & background tasks |
@@ -794,9 +851,20 @@ impl RustwardenBuilder {
| 794 | 851 | async move { security_headers_middleware(cfg, req, next).await } |
| 795 | 852 | } |
| 796 | 853 | })) |
| 797 | - .layer_fn(move |service| AdminService::new(service, db_for_admin.clone())) | |
| 798 | 854 | .layer_fn(move |service| { |
| 799 | - AuthService::new(service, db_for_auth.clone(), prefixes_for_auth.clone()) | |
| 855 | + AdminService::with_auth_config( | |
| 856 | + service, | |
| 857 | + db_for_admin.clone(), | |
| 858 | + auth_config_for_admin.clone(), | |
| 859 | + ) | |
| 860 | + }) | |
| 861 | + .layer_fn(move |service| { | |
| 862 | + AuthService::with_auth_config( | |
| 863 | + service, | |
| 864 | + db_for_auth.clone(), | |
| 865 | + prefixes_for_auth.clone(), | |
| 866 | + auth_config_for_auth.clone(), | |
| 867 | + ) | |
| 800 | 868 | }) |
| 801 | 869 | .layer(session_layer) |
| 802 | 870 | .layer(axum::middleware::from_fn(move |req, next| { |
@@ -1107,6 +1175,101 @@ mod builder_tests {
| 1107 | 1175 | assert!(builder.protected_prefixes.iter().any(|p| p == "/custom")); |
| 1108 | 1176 | } |
| 1109 | 1177 | |
| 1178 | + /// RFC 0042 — precedence of the auth overrides, exercised on the pure | |
| 1179 | + /// `apply_auth_overrides` without touching the process environment. | |
| 1180 | + mod apply_auth_overrides { | |
| 1181 | + use super::*; | |
| 1182 | + use crate::config::auth::{AuthFailureMode, SessionBackend}; | |
| 1183 | + | |
| 1184 | + fn base_config() -> AuthConfig { | |
| 1185 | + AuthConfig { | |
| 1186 | + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), | |
| 1187 | + jwt_expiration_minutes: 15, | |
| 1188 | + jwt_issuer: "test".to_string(), | |
| 1189 | + refresh_token_expiration_days: 7, | |
| 1190 | + remember_me_expiration_days: 30, | |
| 1191 | + session_timeout_minutes: 30, | |
| 1192 | + session_backend: SessionBackend::Memory, | |
| 1193 | + redis_url: None, | |
| 1194 | + rate_limit_login_per_minute: 5, | |
| 1195 | + rate_limit_refresh_per_minute: 10, | |
| 1196 | + rate_limit_general_per_minute: 100, | |
| 1197 | + cookie_secure: false, | |
| 1198 | + cookie_same_site: "Strict".to_string(), | |
| 1199 | + login_path: "/login".to_string(), | |
| 1200 | + auth_failure_mode: AuthFailureMode::Redirect, | |
| 1201 | + } | |
| 1202 | + } | |
| 1203 | + | |
| 1204 | + #[test] | |
| 1205 | + fn headless_flips_default_to_json401() { | |
| 1206 | + let cfg = RustwardenBuilder::default() | |
| 1207 | + .headless() | |
| 1208 | + .apply_auth_overrides(base_config(), false); | |
| 1209 | + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401); | |
| 1210 | + } | |
| 1211 | + | |
| 1212 | + #[test] | |
| 1213 | + fn explicit_setter_wins_over_headless() { | |
| 1214 | + let cfg = RustwardenBuilder::default() | |
| 1215 | + .headless() | |
| 1216 | + .auth_failure_mode(AuthFailureMode::Redirect) | |
| 1217 | + .apply_auth_overrides(base_config(), false); | |
| 1218 | + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect); | |
| 1219 | + } | |
| 1220 | + | |
| 1221 | + #[test] | |
| 1222 | + fn env_value_wins_over_headless_flip() { | |
| 1223 | + // `base_config()` stands for what `from_env()` produced from an | |
| 1224 | + // explicit `AUTH_FAILURE_MODE=redirect`. | |
| 1225 | + let cfg = RustwardenBuilder::default() | |
| 1226 | + .headless() | |
| 1227 | + .apply_auth_overrides(base_config(), true); | |
| 1228 | + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect); | |
| 1229 | + } | |
| 1230 | + | |
| 1231 | + #[test] | |
| 1232 | + fn explicit_setter_wins_over_env_value() { | |
| 1233 | + let cfg = RustwardenBuilder::default() | |
| 1234 | + .auth_failure_mode(AuthFailureMode::Json401) | |
| 1235 | + .apply_auth_overrides(base_config(), true); | |
| 1236 | + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401); | |
| 1237 | + } | |
| 1238 | + | |
| 1239 | + #[test] | |
| 1240 | + fn login_path_setter_is_propagated() { | |
| 1241 | + let cfg = RustwardenBuilder::default() | |
| 1242 | + .login_path("/auth/signin") | |
| 1243 | + .apply_auth_overrides(base_config(), false); | |
| 1244 | + assert_eq!(cfg.login_path, "/auth/signin"); | |
| 1245 | + } | |
| 1246 | + | |
| 1247 | + #[test] | |
| 1248 | + fn non_headless_without_setter_is_unchanged() { | |
| 1249 | + let cfg = RustwardenBuilder::default().apply_auth_overrides(base_config(), false); | |
| 1250 | + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect); | |
| 1251 | + assert_eq!(cfg.login_path, "/login"); | |
| 1252 | + } | |
| 1253 | + | |
| 1254 | + /// Only a *parseable* env value counts as an operator override: an | |
| 1255 | + /// empty or misspelled `AUTH_FAILURE_MODE` makes `from_env` fall back | |
| 1256 | + /// to `Redirect`, and that must not suppress the `headless()` flip. | |
| 1257 | + #[test] | |
| 1258 | + fn env_signal_requires_a_valid_value() { | |
| 1259 | + assert!(!RustwardenBuilder::failure_mode_overridden_by_env(None)); | |
| 1260 | + assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some(""))); | |
| 1261 | + assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some( | |
| 1262 | + "json-401" | |
| 1263 | + ))); | |
| 1264 | + assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some( | |
| 1265 | + "json401" | |
| 1266 | + ))); | |
| 1267 | + assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some( | |
| 1268 | + "Redirect" | |
| 1269 | + ))); | |
| 1270 | + } | |
| 1271 | + } | |
| 1272 | + | |
| 1110 | 1273 | #[test] |
| 1111 | 1274 | fn default_builder_uses_default_security_headers() { |
| 1112 | 1275 | let builder = RustwardenBuilder::default(); |
M
crates/rustwarden-core/src/middleware/admin_service.rs
+194
-26
@@ -10,23 +10,86 @@ use sea_orm::DatabaseConnection;
| 10 | 10 | use std::{ |
| 11 | 11 | future::Future, |
| 12 | 12 | pin::Pin, |
| 13 | + sync::Arc, | |
| 13 | 14 | task::{Context, Poll}, |
| 14 | 15 | }; |
| 15 | 16 | use tower::Service; |
| 16 | 17 | |
| 17 | 18 | use super::auth_service; |
| 19 | +use crate::config::auth::AuthConfig; | |
| 18 | 20 | |
| 19 | 21 | /// Service wrapper that applies admin_required middleware to /admin/* routes |
| 22 | +/// | |
| 23 | +/// The [`AuthConfig`] used to verify the JWT is resolved by | |
| 24 | +/// `auth_service::resolve_auth_config`: the config captured at construction | |
| 25 | +/// first, then `Extension(Arc<AuthConfig>)`, then | |
| 26 | +/// [`AuthConfig::from_env`]. The last two steps are the legacy path of the | |
| 27 | +/// deprecated [`AdminService::new`] constructor and ignore builder overrides | |
| 28 | +/// whenever this middleware is layered outside the `Extension` layer. | |
| 20 | 29 | #[derive(Clone)] |
| 21 | 30 | pub struct AdminService<S> { |
| 22 | 31 | inner: S, |
| 23 | 32 | db: DatabaseConnection, |
| 33 | + /// Config captured at construction. `None` only on the deprecated | |
| 34 | + /// [`AdminService::new`] path. | |
| 35 | + auth_config: Option<Arc<AuthConfig>>, | |
| 24 | 36 | } |
| 25 | 37 | |
| 26 | 38 | impl<S> AdminService<S> { |
| 27 | 39 | /// Create a new `AdminService` middleware wrapping the given inner service. |
| 40 | + #[deprecated( | |
| 41 | + since = "1.2.0", | |
| 42 | + note = "use `AdminService::with_auth_config(inner, db, auth_config)`. Removal no earlier than 1.5.0, effective in the next major." | |
| 43 | + )] | |
| 28 | 44 | pub fn new(inner: S, db: DatabaseConnection) -> Self { |
| 29 | - Self { inner, db } | |
| 45 | + Self { | |
| 46 | + inner, | |
| 47 | + db, | |
| 48 | + auth_config: None, | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Create an `AdminService` that carries its own resolved | |
| 53 | + /// [`AuthConfig`] — the supported constructor since RFC 0042. | |
| 54 | + /// | |
| 55 | + /// The config is captured **once at construction**, the same pattern | |
| 56 | + /// `security_headers_middleware` uses in | |
| 57 | + /// [`crate::app::RustwardenBuilder::build`]. Consequences: | |
| 58 | + /// | |
| 59 | + /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`) | |
| 60 | + /// are honoured regardless of tower layer order. A middleware layered | |
| 61 | + /// *outside* `Extension(Arc<AuthConfig>)` can never observe that | |
| 62 | + /// extension, which is what made [`AdminService::new`] fall back to | |
| 63 | + /// [`AuthConfig::from_env`] and discard those overrides. | |
| 64 | + /// - The captured config takes precedence over any | |
| 65 | + /// `Extension(Arc<AuthConfig>)` present in the request. | |
| 66 | + /// - On every request the middleware inserts the captured `Arc` into the | |
| 67 | + /// request extensions, so downstream extractors (`AuthUser`, | |
| 68 | + /// `auth_required`, `admin_required`) observe the same instance even in | |
| 69 | + /// hand-composed tower stacks. In the canonical wiring the inner | |
| 70 | + /// `Extension` layer re-inserts the very same `Arc`, a no-op. | |
| 71 | + /// | |
| 72 | + /// ```no_run | |
| 73 | + /// # use std::sync::Arc; | |
| 74 | + /// # use rustwarden_core::config::auth::AuthConfig; | |
| 75 | + /// # use rustwarden_core::middleware::admin_service::AdminService; | |
| 76 | + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { | |
| 77 | + /// let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 78 | + /// let app = router.layer(tower::layer::layer_fn(move |inner| { | |
| 79 | + /// AdminService::with_auth_config(inner, db.clone(), auth_config.clone()) | |
| 80 | + /// })); | |
| 81 | + /// # let _ = app; Ok(()) } | |
| 82 | + /// ``` | |
| 83 | + pub fn with_auth_config( | |
| 84 | + inner: S, | |
| 85 | + db: DatabaseConnection, | |
| 86 | + auth_config: Arc<AuthConfig>, | |
| 87 | + ) -> Self { | |
| 88 | + Self { | |
| 89 | + inner, | |
| 90 | + db, | |
| 91 | + auth_config: Some(auth_config), | |
| 92 | + } | |
| 30 | 93 | } |
| 31 | 94 | } |
| 32 | 95 |
@@ -44,8 +107,19 @@ where
| 44 | 107 | self.inner.poll_ready(cx) |
| 45 | 108 | } |
| 46 | 109 | |
| 47 | - fn call(&mut self, req: Request<B>) -> Self::Future { | |
| 110 | + fn call(&mut self, mut req: Request<B>) -> Self::Future { | |
| 48 | 111 | let uri = req.uri().clone(); |
| 112 | + | |
| 113 | + // Seed the captured config into the request extensions so downstream | |
| 114 | + // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve | |
| 115 | + // the exact same instance. In the canonical wiring the later | |
| 116 | + // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which | |
| 117 | + // is a no-op; seeding only matters for hand-composed tower stacks. | |
| 118 | + if let Some(config) = &self.auth_config { | |
| 119 | + req.extensions_mut().insert(config.clone()); | |
| 120 | + } | |
| 121 | + let captured = self.auth_config.clone(); | |
| 122 | + | |
| 49 | 123 | let path = uri.path(); |
| 50 | 124 | |
| 51 | 125 | // Only apply admin_required middleware to /admin/* and /api/v1/admin/* routes |
@@ -61,33 +135,25 @@ where
| 61 | 135 | let (parts, body) = req.into_parts(); |
| 62 | 136 | |
| 63 | 137 | use crate::auth::jwt::JwtService; |
| 64 | - use crate::config::auth::AuthConfig; | |
| 65 | 138 | use crate::services::jwt_blacklist_service::JwtBlacklistService; |
| 66 | 139 | |
| 67 | 140 | // Resolve auth config up-front so every auth-failure branch |
| 68 | - // honours `login_path` / `auth_failure_mode`. Prefer the | |
| 69 | - // injected `Extension`; fall back to `AuthConfig::from_env()` | |
| 70 | - // when the layer order puts the Extension further inside the | |
| 71 | - // stack (same regression as `AuthService` since `d4d6828`). | |
| 72 | - let auth_config = match parts | |
| 73 | - .extensions | |
| 74 | - .get::<std::sync::Arc<AuthConfig>>() | |
| 75 | - .cloned() | |
| 76 | - { | |
| 77 | - Some(config) => config, | |
| 78 | - None => match AuthConfig::from_env() { | |
| 79 | - Ok(cfg) => std::sync::Arc::new(cfg), | |
| 80 | - Err(err) => { | |
| 81 | - tracing::error!( | |
| 82 | - "AdminService: AuthConfig unavailable (no extension and from_env failed): {err}" | |
| 83 | - ); | |
| 84 | - return Ok(( | |
| 85 | - axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 86 | - "Internal server error", | |
| 87 | - ) | |
| 88 | - .into_response()); | |
| 89 | - } | |
| 90 | - }, | |
| 141 | + // honours `login_path` / `auth_failure_mode`. | |
| 142 | + let auth_config = match crate::middleware::auth_service::resolve_auth_config( | |
| 143 | + captured.as_ref(), | |
| 144 | + &parts.extensions, | |
| 145 | + ) { | |
| 146 | + Ok(config) => config, | |
| 147 | + Err(err) => { | |
| 148 | + tracing::error!( | |
| 149 | + "AdminService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}" | |
| 150 | + ); | |
| 151 | + return Ok(( | |
| 152 | + axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 153 | + "Internal server error", | |
| 154 | + ) | |
| 155 | + .into_response()); | |
| 156 | + } | |
| 91 | 157 | }; |
| 92 | 158 | |
| 93 | 159 | // Extract JWT token from parts |
@@ -152,3 +218,105 @@ where
| 152 | 218 | } |
| 153 | 219 | } |
| 154 | 220 | } |
| 221 | + | |
| 222 | +#[cfg(test)] | |
| 223 | +mod tests { | |
| 224 | + use super::AdminService; | |
| 225 | + use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; | |
| 226 | + use axum::body::Body; | |
| 227 | + use axum::http::{Request as HttpRequest, StatusCode, header::LOCATION}; | |
| 228 | + use axum::response::Response; | |
| 229 | + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; | |
| 230 | + use std::sync::Arc; | |
| 231 | + use tower::ServiceExt; // for `oneshot` | |
| 232 | + | |
| 233 | + fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { | |
| 234 | + AuthConfig { | |
| 235 | + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), | |
| 236 | + jwt_expiration_minutes: 15, | |
| 237 | + jwt_issuer: "test".to_string(), | |
| 238 | + refresh_token_expiration_days: 7, | |
| 239 | + remember_me_expiration_days: 30, | |
| 240 | + session_timeout_minutes: 30, | |
| 241 | + session_backend: SessionBackend::Memory, | |
| 242 | + redis_url: None, | |
| 243 | + rate_limit_login_per_minute: 5, | |
| 244 | + rate_limit_refresh_per_minute: 10, | |
| 245 | + rate_limit_general_per_minute: 100, | |
| 246 | + cookie_secure: false, | |
| 247 | + cookie_same_site: "Strict".to_string(), | |
| 248 | + login_path: login_path.to_string(), | |
| 249 | + auth_failure_mode: mode, | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + fn mock_db() -> DatabaseConnection { | |
| 254 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection() | |
| 255 | + } | |
| 256 | + | |
| 257 | + /// Router wrapped with `AdminService::with_auth_config` and **no** | |
| 258 | + /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only | |
| 259 | + /// source of truth, exactly as RFC 0042 requires. | |
| 260 | + fn admin_router(cfg: AuthConfig) -> axum::Router { | |
| 261 | + let db = mock_db(); | |
| 262 | + let cfg = Arc::new(cfg); | |
| 263 | + axum::Router::new() | |
| 264 | + .route("/admin", axum::routing::get(|| async { "admin" })) | |
| 265 | + .route( | |
| 266 | + "/api/v1/admin/x", | |
| 267 | + axum::routing::get(|| async { "admin api" }), | |
| 268 | + ) | |
| 269 | + .layer(tower::layer::layer_fn(move |inner| { | |
| 270 | + AdminService::with_auth_config(inner, db.clone(), cfg.clone()) | |
| 271 | + })) | |
| 272 | + } | |
| 273 | + | |
| 274 | + async fn get(router: axum::Router, uri: &str) -> Response { | |
| 275 | + let req = HttpRequest::builder() | |
| 276 | + .uri(uri) | |
| 277 | + .body(Body::empty()) | |
| 278 | + .expect("request"); | |
| 279 | + router.oneshot(req).await.expect("oneshot") | |
| 280 | + } | |
| 281 | + | |
| 282 | + async fn body_string(resp: Response) -> String { | |
| 283 | + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) | |
| 284 | + .await | |
| 285 | + .expect("body"); | |
| 286 | + String::from_utf8(bytes.to_vec()).expect("utf-8 body") | |
| 287 | + } | |
| 288 | + | |
| 289 | + /// T7 — both admin prefixes honour the captured `Json401` mode without | |
| 290 | + /// any environment variable or `Extension` layer. | |
| 291 | + #[tokio::test] | |
| 292 | + async fn admin_routes_without_jwt_return_json_401_from_the_captured_config() { | |
| 293 | + for uri in ["/admin", "/api/v1/admin/x"] { | |
| 294 | + let resp = get( | |
| 295 | + admin_router(test_config(AuthFailureMode::Json401, "/login")), | |
| 296 | + uri, | |
| 297 | + ) | |
| 298 | + .await; | |
| 299 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "uri = {uri}"); | |
| 300 | + assert!(resp.headers().get(LOCATION).is_none(), "uri = {uri}"); | |
| 301 | + let body = body_string(resp).await; | |
| 302 | + assert!(body.contains("unauthorized"), "uri = {uri}, body = {body}"); | |
| 303 | + } | |
| 304 | + } | |
| 305 | + | |
| 306 | + /// T7 — `Redirect` mode honours the captured `login_path` override that | |
| 307 | + /// the deprecated `AdminService::new` path used to discard. | |
| 308 | + #[tokio::test] | |
| 309 | + async fn admin_route_redirect_mode_uses_the_captured_login_path() { | |
| 310 | + let resp = get( | |
| 311 | + admin_router(test_config(AuthFailureMode::Redirect, "/auth/signin")), | |
| 312 | + "/admin", | |
| 313 | + ) | |
| 314 | + .await; | |
| 315 | + | |
| 316 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 317 | + assert_eq!( | |
| 318 | + resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()), | |
| 319 | + Some("/auth/signin?redirect=%2Fadmin") | |
| 320 | + ); | |
| 321 | + } | |
| 322 | +} |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+366
-61
@@ -11,7 +11,7 @@ use sea_orm::DatabaseConnection;
| 11 | 11 | use std::{ |
| 12 | 12 | future::Future, |
| 13 | 13 | pin::Pin, |
| 14 | - sync::Arc, | |
| 14 | + sync::{Arc, Once}, | |
| 15 | 15 | task::{Context, Poll}, |
| 16 | 16 | }; |
| 17 | 17 | use tower::Service; |
@@ -137,19 +137,29 @@ pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> T
| 137 | 137 | /// `Option<Extension<Claims>>`. |
| 138 | 138 | /// |
| 139 | 139 | /// Both branches resolve the [`crate::config::auth::AuthConfig`] |
| 140 | -/// required for JWT verification via the same two-step lookup: | |
| 140 | +/// required for JWT verification via the same three-step lookup | |
| 141 | +/// (RFC 0042): | |
| 141 | 142 | /// |
| 142 | -/// 1. Read `Extension(Arc<AuthConfig>)` from the request extensions | |
| 143 | -/// when a downstream layer has posed it (the canonical wiring | |
| 144 | -/// performed by [`crate::app::RustwardenBuilder::build`]). | |
| 145 | -/// 2. Otherwise fall back to | |
| 146 | -/// [`crate::config::auth::AuthConfig::from_env`] so the | |
| 147 | -/// middleware keeps working when the layer ordering puts the | |
| 148 | -/// `Extension` layer further inside the stack. The protected | |
| 149 | -/// branch was patched first (commit `d4d6828`, RFC 0022); the | |
| 150 | -/// optional branch was aligned by RFC 0031. | |
| 143 | +/// 1. The `Arc<AuthConfig>` captured at construction time by | |
| 144 | +/// [`AuthService::with_auth_config`]. This is the authoritative | |
| 145 | +/// source: it is immune to tower layer ordering, so builder | |
| 146 | +/// overrides (`login_path`, `auth_failure_mode`, `headless`) are | |
| 147 | +/// always honoured. | |
| 148 | +/// 2. `Extension(Arc<AuthConfig>)` read from the request extensions | |
| 149 | +/// when a downstream layer has posed it. | |
| 150 | +/// 3. [`crate::config::auth::AuthConfig::from_env`] as a last resort, | |
| 151 | +/// reported once per process through a `tracing::warn!`. | |
| 151 | 152 | /// |
| 152 | -/// On the protected branch, a failed `from_env` fallback produces a | |
| 153 | +/// Steps 2 and 3 are the **legacy path of the deprecated | |
| 154 | +/// [`AuthService::new`] constructor**. They silently drop builder | |
| 155 | +/// overrides whenever the `Extension` layer sits further inside the | |
| 156 | +/// stack than this middleware — which is precisely what | |
| 157 | +/// [`crate::app::RustwardenBuilder::build`] does, since both services | |
| 158 | +/// are layered outside `Extension(Arc<AuthConfig>)`. The protected | |
| 159 | +/// branch was patched first (commit `d4d6828`, RFC 0022); the optional | |
| 160 | +/// branch was aligned by RFC 0031; RFC 0042 added step 1. | |
| 161 | +/// | |
| 162 | +/// On the protected branch, a failed resolution produces a | |
| 153 | 163 | /// `500 Internal Server Error` after a `tracing::error!`. On the |
| 154 | 164 | /// optional branch, the request simply traverses without claims — |
| 155 | 165 | /// matching the semantics of an unauthenticated visitor — and a |
@@ -163,6 +173,10 @@ pub struct AuthService<S> {
| 163 | 173 | inner: S, |
| 164 | 174 | db: DatabaseConnection, |
| 165 | 175 | protected_prefixes: Arc<Vec<String>>, |
| 176 | + /// Config captured at construction. `None` only on the deprecated | |
| 177 | + /// [`AuthService::new`] path, where the request extension / environment | |
| 178 | + /// fallback applies. | |
| 179 | + auth_config: Option<Arc<AuthConfig>>, | |
| 166 | 180 | } |
| 167 | 181 | |
| 168 | 182 | impl<S> AuthService<S> { |
@@ -175,15 +189,104 @@ impl<S> AuthService<S> {
| 175 | 189 | /// such as `rustwarden-fediverse` register their own prefixes |
| 176 | 190 | /// (`/fediverse/consent`, `/fediverse/invite`) via the builder before |
| 177 | 191 | /// the app boots. |
| 192 | + #[deprecated( | |
| 193 | + since = "1.2.0", | |
| 194 | + 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." | |
| 195 | + )] | |
| 178 | 196 | pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self { |
| 179 | 197 | Self { |
| 180 | 198 | inner, |
| 181 | 199 | db, |
| 182 | 200 | protected_prefixes, |
| 201 | + auth_config: None, | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + /// Create an `AuthService` that carries its own resolved | |
| 206 | + /// [`AuthConfig`] — the supported constructor since RFC 0042. | |
| 207 | + /// | |
| 208 | + /// The config is captured **once at construction**, the same pattern | |
| 209 | + /// `security_headers_middleware` uses in | |
| 210 | + /// [`crate::app::RustwardenBuilder::build`]. Consequences: | |
| 211 | + /// | |
| 212 | + /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`) | |
| 213 | + /// are honoured regardless of tower layer order. A middleware layered | |
| 214 | + /// *outside* `Extension(Arc<AuthConfig>)` can never observe that | |
| 215 | + /// extension, which is what made [`AuthService::new`] fall back to | |
| 216 | + /// [`AuthConfig::from_env`] and discard those overrides. | |
| 217 | + /// - The captured config takes precedence over any | |
| 218 | + /// `Extension(Arc<AuthConfig>)` present in the request. | |
| 219 | + /// - On every request the middleware inserts the captured `Arc` into the | |
| 220 | + /// request extensions, so downstream extractors (`AuthUser`, | |
| 221 | + /// `auth_required`, `admin_required`) observe the same instance even in | |
| 222 | + /// hand-composed tower stacks that have no `Extension` layer of their | |
| 223 | + /// own. In the canonical wiring the inner `Extension` layer re-inserts | |
| 224 | + /// the very same `Arc`, which is a no-op. | |
| 225 | + /// | |
| 226 | + /// `protected_prefixes` has the same meaning as in | |
| 227 | + /// [`AuthService::new`]. | |
| 228 | + /// | |
| 229 | + /// ```no_run | |
| 230 | + /// # use std::sync::Arc; | |
| 231 | + /// # use rustwarden_core::config::auth::AuthConfig; | |
| 232 | + /// # use rustwarden_core::middleware::auth_service::AuthService; | |
| 233 | + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { | |
| 234 | + /// let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 235 | + /// let prefixes = Arc::new(vec!["/api/private".to_string()]); | |
| 236 | + /// let app = router.layer(tower::layer::layer_fn(move |inner| { | |
| 237 | + /// AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone()) | |
| 238 | + /// })); | |
| 239 | + /// # let _ = app; Ok(()) } | |
| 240 | + /// ``` | |
| 241 | + pub fn with_auth_config( | |
| 242 | + inner: S, | |
| 243 | + db: DatabaseConnection, | |
| 244 | + protected_prefixes: Arc<Vec<String>>, | |
| 245 | + auth_config: Arc<AuthConfig>, | |
| 246 | + ) -> Self { | |
| 247 | + Self { | |
| 248 | + inner, | |
| 249 | + db, | |
| 250 | + protected_prefixes, | |
| 251 | + auth_config: Some(auth_config), | |
| 183 | 252 | } |
| 184 | 253 | } |
| 185 | 254 | } |
| 186 | 255 | |
| 256 | +/// Emitted at most once per process when the deprecated resolution path | |
| 257 | +/// reaches [`AuthConfig::from_env`] (RFC 0042, OQ5). | |
| 258 | +static DEPRECATED_CONFIG_PATH_WARNING: Once = Once::new(); | |
| 259 | + | |
| 260 | +/// Resolve the [`AuthConfig`] a request must be authenticated against. | |
| 261 | +/// | |
| 262 | +/// Authoritative config first; request extension then environment only on | |
| 263 | +/// the deprecated path. A free function so it can be called from inside the | |
| 264 | +/// `'static` boxed future returned by `call` (where `self` is unavailable). | |
| 265 | +pub(crate) fn resolve_auth_config( | |
| 266 | + captured: Option<&Arc<AuthConfig>>, | |
| 267 | + extensions: &axum::http::Extensions, | |
| 268 | +) -> Result<Arc<AuthConfig>, String> { | |
| 269 | + if let Some(config) = captured { | |
| 270 | + return Ok(config.clone()); | |
| 271 | + } | |
| 272 | + if let Some(config) = extensions.get::<Arc<AuthConfig>>() { | |
| 273 | + return Ok(config.clone()); | |
| 274 | + } | |
| 275 | + let config = AuthConfig::from_env().map(Arc::new)?; | |
| 276 | + // Warn only once the fallback actually succeeded, so the message never | |
| 277 | + // precedes a `from_env failed` error for the same request. | |
| 278 | + DEPRECATED_CONFIG_PATH_WARNING.call_once(|| { | |
| 279 | + tracing::warn!( | |
| 280 | + "AuthConfig resolved from the environment: the deprecated \ | |
| 281 | + `AuthService::new` / `AdminService::new` constructor path is in use, \ | |
| 282 | + so builder overrides (login_path, auth_failure_mode, headless) are \ | |
| 283 | + ignored. Switch to `with_auth_config(..)`. This warning is emitted \ | |
| 284 | + once per process." | |
| 285 | + ); | |
| 286 | + }); | |
| 287 | + Ok(config) | |
| 288 | +} | |
| 289 | + | |
| 187 | 290 | impl<S, B> Service<Request<B>> for AuthService<S> |
| 188 | 291 | where |
| 189 | 292 | S: Service<Request<B>, Response = Response> + Clone + Send + 'static, |
@@ -198,10 +301,20 @@ where
| 198 | 301 | self.inner.poll_ready(cx) |
| 199 | 302 | } |
| 200 | 303 | |
| 201 | - fn call(&mut self, req: Request<B>) -> Self::Future { | |
| 304 | + fn call(&mut self, mut req: Request<B>) -> Self::Future { | |
| 202 | 305 | let uri = req.uri().clone(); |
| 203 | 306 | let path = uri.path().to_owned(); |
| 204 | 307 | |
| 308 | + // Seed the captured config into the request extensions so downstream | |
| 309 | + // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve | |
| 310 | + // the exact same instance. In the canonical wiring the later | |
| 311 | + // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which | |
| 312 | + // is a no-op; seeding only matters for hand-composed tower stacks. | |
| 313 | + if let Some(config) = &self.auth_config { | |
| 314 | + req.extensions_mut().insert(config.clone()); | |
| 315 | + } | |
| 316 | + let captured = self.auth_config.clone(); | |
| 317 | + | |
| 205 | 318 | // NOTE on `/api/v1/teams/*` : *not* protected here on purpose. |
| 206 | 319 | // The canonical admin path is the UI handler |
| 207 | 320 | // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite` |
@@ -232,29 +345,18 @@ where
| 232 | 345 | |
| 233 | 346 | // Resolve AuthConfig up-front so every authentication-failure |
| 234 | 347 | // branch below can honour `login_path` / `auth_failure_mode`. |
| 235 | - // Prefer the injected `Extension`; fall back to | |
| 236 | - // `AuthConfig::from_env()` when the layer order puts the | |
| 237 | - // Extension layer further inside the stack — the regression | |
| 238 | - // commit `d4d6828` flipped this path to a hard 500 by mistake. | |
| 239 | - let auth_config = match parts | |
| 240 | - .extensions | |
| 241 | - .get::<std::sync::Arc<AuthConfig>>() | |
| 242 | - .cloned() | |
| 243 | - { | |
| 244 | - Some(config) => config, | |
| 245 | - None => match AuthConfig::from_env() { | |
| 246 | - Ok(cfg) => std::sync::Arc::new(cfg), | |
| 247 | - Err(err) => { | |
| 248 | - tracing::error!( | |
| 249 | - "AuthService: AuthConfig unavailable (no extension and from_env failed): {err}" | |
| 250 | - ); | |
| 251 | - return Ok(( | |
| 252 | - axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 253 | - "Internal server error", | |
| 254 | - ) | |
| 255 | - .into_response()); | |
| 256 | - } | |
| 257 | - }, | |
| 348 | + let auth_config = match resolve_auth_config(captured.as_ref(), &parts.extensions) { | |
| 349 | + Ok(config) => config, | |
| 350 | + Err(err) => { | |
| 351 | + tracing::error!( | |
| 352 | + "AuthService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}" | |
| 353 | + ); | |
| 354 | + return Ok(( | |
| 355 | + axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 356 | + "Internal server error", | |
| 357 | + ) | |
| 358 | + .into_response()); | |
| 359 | + } | |
| 258 | 360 | }; |
| 259 | 361 | |
| 260 | 362 | let jwt_token = |
@@ -453,7 +555,6 @@ where
| 453 | 555 | crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) |
| 454 | 556 | { |
| 455 | 557 | use crate::auth::jwt::JwtService; |
| 456 | - use crate::config::auth::AuthConfig; | |
| 457 | 558 | use crate::services::jwt_blacklist_service::JwtBlacklistService; |
| 458 | 559 | |
| 459 | 560 | let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token) |
@@ -461,27 +562,22 @@ where
| 461 | 562 | .unwrap_or(true); |
| 462 | 563 | |
| 463 | 564 | if !is_blacklisted { |
| 464 | - // RFC 0031: mirror the protected branch fallback so | |
| 465 | - // routes outside `protected_prefixes` still see | |
| 466 | - // injected claims when the `Extension(Arc<AuthConfig>)` | |
| 467 | - // layer runs *after* `AuthService` in the tower | |
| 468 | - // ordering. Without this fallback, `claims` was | |
| 469 | - // silently `None` for all optional-auth routes. | |
| 470 | - let auth_config = parts | |
| 471 | - .extensions | |
| 472 | - .get::<std::sync::Arc<AuthConfig>>() | |
| 473 | - .cloned() | |
| 474 | - .or_else(|| { | |
| 475 | - AuthConfig::from_env() | |
| 476 | - .map(std::sync::Arc::new) | |
| 477 | - .map_err(|e| { | |
| 478 | - tracing::error!( | |
| 479 | - "AuthService optional_auth: AuthConfig fallback failed: {e}" | |
| 480 | - ); | |
| 481 | - e | |
| 482 | - }) | |
| 483 | - .ok() | |
| 484 | - }); | |
| 565 | + // RFC 0031 / RFC 0042: mirror the protected branch | |
| 566 | + // resolution so routes outside `protected_prefixes` | |
| 567 | + // still see injected claims, whatever the tower | |
| 568 | + // ordering of the `Extension(Arc<AuthConfig>)` layer. | |
| 569 | + // Without it, `claims` was silently `None` for all | |
| 570 | + // optional-auth routes. | |
| 571 | + let auth_config = | |
| 572 | + match resolve_auth_config(captured.as_ref(), &parts.extensions) { | |
| 573 | + Ok(config) => Some(config), | |
| 574 | + Err(e) => { | |
| 575 | + tracing::error!( | |
| 576 | + "AuthService optional_auth: AuthConfig fallback failed: {e}" | |
| 577 | + ); | |
| 578 | + None | |
| 579 | + } | |
| 580 | + }; | |
| 485 | 581 | |
| 486 | 582 | if let Some(auth_config) = auth_config { |
| 487 | 583 | if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) |
@@ -505,11 +601,220 @@ where
| 505 | 601 | #[cfg(test)] |
| 506 | 602 | mod tests { |
| 507 | 603 | use super::{ |
| 508 | - TotpGate, UserLookup, auth_failure_response, map_user_lookup, sanitize_redirect, | |
| 509 | - totp_force_gate, | |
| 604 | + AuthService, TotpGate, UserLookup, auth_failure_response, map_user_lookup, | |
| 605 | + sanitize_redirect, totp_force_gate, | |
| 510 | 606 | }; |
| 511 | 607 | use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; |
| 512 | - use axum::http::{StatusCode, Uri, header::LOCATION}; | |
| 608 | + use axum::body::Body; | |
| 609 | + use axum::http::{Request as HttpRequest, StatusCode, Uri, header::LOCATION}; | |
| 610 | + use axum::response::Response; | |
| 611 | + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value}; | |
| 612 | + use std::collections::BTreeMap; | |
| 613 | + use std::sync::Arc; | |
| 614 | + use tower::ServiceExt; // for `oneshot` | |
| 615 | + | |
| 616 | + /// Mock connection that answers no query: any DB access is a test failure | |
| 617 | + /// (its transaction log is asserted empty by the "no token" tests). | |
| 618 | + fn mock_db() -> DatabaseConnection { | |
| 619 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection() | |
| 620 | + } | |
| 621 | + | |
| 622 | + /// Row shape SeaORM 2.0 expects for `Select::count()`: | |
| 623 | + /// `SELECT COUNT(*) AS num_items FROM (..) AS sub_query`. | |
| 624 | + fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> { | |
| 625 | + BTreeMap::from([("num_items", Value::BigInt(Some(n)))]) | |
| 626 | + } | |
| 627 | + | |
| 628 | + /// Mock connection whose single query result is the JWT-blacklist COUNT. | |
| 629 | + fn mock_db_with_blacklist_count(n: i64) -> DatabaseConnection { | |
| 630 | + MockDatabase::new(DatabaseBackend::Postgres) | |
| 631 | + .append_query_results([vec![blacklist_count(n)]]) | |
| 632 | + .into_connection() | |
| 633 | + } | |
| 634 | + | |
| 635 | + /// Number of SQL statements the mock connection actually received. | |
| 636 | + fn statement_count(db: DatabaseConnection) -> usize { | |
| 637 | + db.into_transaction_log() | |
| 638 | + .iter() | |
| 639 | + .map(|txn| txn.statements().len()) | |
| 640 | + .sum() | |
| 641 | + } | |
| 642 | + | |
| 643 | + /// Router wrapped with `AuthService::with_auth_config` and **no** | |
| 644 | + /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only | |
| 645 | + /// source of truth, exactly as RFC 0042 requires. | |
| 646 | + fn auth_router(db: DatabaseConnection, prefixes: &[&str], cfg: AuthConfig) -> axum::Router { | |
| 647 | + let prefixes = Arc::new( | |
| 648 | + prefixes | |
| 649 | + .iter() | |
| 650 | + .map(|p| (*p).to_string()) | |
| 651 | + .collect::<Vec<_>>(), | |
| 652 | + ); | |
| 653 | + let cfg = Arc::new(cfg); | |
| 654 | + axum::Router::new() | |
| 655 | + .route("/private", axum::routing::get(|| async { "private" })) | |
| 656 | + .route( | |
| 657 | + "/public", | |
| 658 | + axum::routing::get( | |
| 659 | + |axum::Extension(cfg): axum::Extension<Arc<AuthConfig>>| async move { | |
| 660 | + cfg.login_path.clone() | |
| 661 | + }, | |
| 662 | + ), | |
| 663 | + ) | |
| 664 | + .layer(tower::layer::layer_fn(move |inner| { | |
| 665 | + AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), cfg.clone()) | |
| 666 | + })) | |
| 667 | + } | |
| 668 | + | |
| 669 | + async fn send(router: axum::Router, uri: &str, bearer: Option<&str>) -> Response { | |
| 670 | + let mut builder = HttpRequest::builder().uri(uri); | |
| 671 | + if let Some(token) = bearer { | |
| 672 | + builder = builder.header("authorization", format!("Bearer {token}")); | |
| 673 | + } | |
| 674 | + let req = builder.body(Body::empty()).expect("request"); | |
| 675 | + router.oneshot(req).await.expect("oneshot") | |
| 676 | + } | |
| 677 | + | |
| 678 | + async fn body_string(resp: Response) -> String { | |
| 679 | + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) | |
| 680 | + .await | |
| 681 | + .expect("body"); | |
| 682 | + String::from_utf8(bytes.to_vec()).expect("utf-8 body") | |
| 683 | + } | |
| 684 | + | |
| 685 | + fn location(resp: &Response) -> Option<&str> { | |
| 686 | + resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) | |
| 687 | + } | |
| 688 | + | |
| 689 | + /// T1 — protected prefix, no credentials, `Json401`: the captured config | |
| 690 | + /// drives the 401 JSON response and no DB round-trip happens at all. | |
| 691 | + #[tokio::test] | |
| 692 | + async fn protected_route_without_jwt_returns_json_401_without_touching_the_database() { | |
| 693 | + let db = mock_db(); | |
| 694 | + let router = auth_router( | |
| 695 | + db.clone(), | |
| 696 | + &["/private"], | |
| 697 | + test_config(AuthFailureMode::Json401, "/login"), | |
| 698 | + ); | |
| 699 | + | |
| 700 | + let resp = send(router, "/private", None).await; | |
| 701 | + | |
| 702 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 703 | + assert!(location(&resp).is_none()); | |
| 704 | + let body = body_string(resp).await; | |
| 705 | + assert!(body.contains("unauthorized"), "unexpected body: {body}"); | |
| 706 | + assert_eq!( | |
| 707 | + statement_count(db), | |
| 708 | + 0, | |
| 709 | + "a missing token must short-circuit before any query" | |
| 710 | + ); | |
| 711 | + } | |
| 712 | + | |
| 713 | + /// T2 — protected prefix, no credentials, `Redirect`: the captured | |
| 714 | + /// `login_path` override is honoured even though no `Extension` layer | |
| 715 | + /// exists and no environment variable is set. | |
| 716 | + #[tokio::test] | |
| 717 | + async fn protected_route_redirect_mode_uses_the_captured_login_path() { | |
| 718 | + let router = auth_router( | |
| 719 | + mock_db(), | |
| 720 | + &["/private"], | |
| 721 | + test_config(AuthFailureMode::Redirect, "/auth/signin"), | |
| 722 | + ); | |
| 723 | + | |
| 724 | + let resp = send(router, "/private", None).await; | |
| 725 | + | |
| 726 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 727 | + assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate")); | |
| 728 | + } | |
| 729 | + | |
| 730 | + /// T3 — the captured config is seeded into the request extensions, so a | |
| 731 | + /// handler on a non-protected route can extract it although the test | |
| 732 | + /// stack declares no `Extension` layer at all. | |
| 733 | + #[tokio::test] | |
| 734 | + async fn captured_config_is_seeded_into_the_request_extensions() { | |
| 735 | + let router = auth_router( | |
| 736 | + mock_db(), | |
| 737 | + &["/private"], | |
| 738 | + test_config(AuthFailureMode::Json401, "/auth/signin"), | |
| 739 | + ); | |
| 740 | + | |
| 741 | + let resp = send(router, "/public", None).await; | |
| 742 | + | |
| 743 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 744 | + assert_eq!(body_string(resp).await, "/auth/signin"); | |
| 745 | + } | |
| 746 | + | |
| 747 | + /// T4 — a JWT signed with a foreign secret must be rejected by | |
| 748 | + /// `JwtService::validate_token` against the *captured* config. The | |
| 749 | + /// blacklist COUNT is mocked to 0 so the flow actually reaches the | |
| 750 | + /// signature check, and it is the only statement the DB sees. | |
| 751 | + #[tokio::test] | |
| 752 | + async fn protected_route_rejects_jwt_signed_with_another_secret() { | |
| 753 | + use crate::auth::jwt::JwtService; | |
| 754 | + | |
| 755 | + let cfg = test_config(AuthFailureMode::Json401, "/login"); | |
| 756 | + let mut foreign_cfg = test_config(AuthFailureMode::Json401, "/login"); | |
| 757 | + foreign_cfg.jwt_secret = "fedcba9876543210fedcba9876543210".to_string(); | |
| 758 | + let foreign_token = JwtService::generate_token( | |
| 759 | + &foreign_cfg, | |
| 760 | + uuid::Uuid::new_v4(), | |
| 761 | + "intruder", | |
| 762 | + Vec::new(), | |
| 763 | + Vec::new(), | |
| 764 | + ) | |
| 765 | + .expect("foreign token"); | |
| 766 | + assert!( | |
| 767 | + JwtService::validate_token(&cfg, &foreign_token).is_err(), | |
| 768 | + "the token must not verify against the captured config" | |
| 769 | + ); | |
| 770 | + | |
| 771 | + let db = mock_db_with_blacklist_count(0); | |
| 772 | + let router = auth_router(db.clone(), &["/private"], cfg); | |
| 773 | + | |
| 774 | + let resp = send(router, "/private", Some(&foreign_token)).await; | |
| 775 | + | |
| 776 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 777 | + assert_eq!( | |
| 778 | + statement_count(db), | |
| 779 | + 1, | |
| 780 | + "only the JWT-blacklist COUNT should reach the database" | |
| 781 | + ); | |
| 782 | + } | |
| 783 | + | |
| 784 | + /// T5 — a JWT that *is* valid for the captured config is still refused | |
| 785 | + /// when the blacklist COUNT reports it as revoked. | |
| 786 | + #[tokio::test] | |
| 787 | + async fn protected_route_rejects_blacklisted_but_valid_jwt() { | |
| 788 | + use crate::auth::jwt::JwtService; | |
| 789 | + | |
| 790 | + let cfg = test_config(AuthFailureMode::Json401, "/login"); | |
| 791 | + let token = JwtService::generate_token( | |
| 792 | + &cfg, | |
| 793 | + uuid::Uuid::new_v4(), | |
| 794 | + "alice", | |
| 795 | + vec!["user".to_string()], | |
| 796 | + Vec::new(), | |
| 797 | + ) | |
| 798 | + .expect("token"); | |
| 799 | + assert!( | |
| 800 | + JwtService::validate_token(&cfg, &token).is_ok(), | |
| 801 | + "the token must verify against the captured config" | |
| 802 | + ); | |
| 803 | + | |
| 804 | + let db = mock_db_with_blacklist_count(1); | |
| 805 | + let router = auth_router(db.clone(), &["/private"], cfg); | |
| 806 | + | |
| 807 | + let resp = send(router, "/private", Some(&token)).await; | |
| 808 | + | |
| 809 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 810 | + let body = body_string(resp).await; | |
| 811 | + assert!(body.contains("unauthorized"), "unexpected body: {body}"); | |
| 812 | + assert_eq!( | |
| 813 | + statement_count(db), | |
| 814 | + 1, | |
| 815 | + "the blacklist COUNT must short-circuit before the user lookup" | |
| 816 | + ); | |
| 817 | + } | |
| 513 | 818 | |
| 514 | 819 | fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { |
| 515 | 820 | AuthConfig { |
A
crates/rustwarden-core/tests/auth_service_resolved_config.rs
+237
-0
@@ -0,0 +1,237 @@
| 1 | +//! RFC 0042 — `AuthService` / `AdminService` carry the `AuthConfig` resolved | |
| 2 | +//! at construction (`with_auth_config`), so builder overrides are honoured | |
| 3 | +//! and the per-request `AuthConfig::from_env()` is gone from the hot path. | |
| 4 | +//! | |
| 5 | +//! This file is a **separate test binary** on purpose: several `mod tests` | |
| 6 | +//! of the library binary call `dotenvy::dotenv()` and load the repository | |
| 7 | +//! `.env` (which contains `JWT_SECRET`) into the process. Here, with | |
| 8 | +//! `#[serial_test::serial(env)]` serialising every test that mutates the | |
| 9 | +//! environment (same intent as the mutex of | |
| 10 | +//! `middleware_authconfig_fallback.rs`, without holding a `MutexGuard` | |
| 11 | +//! across `.await`), we can really remove `JWT_SECRET` and prove that: | |
| 12 | +//! | |
| 13 | +//! - `with_auth_config` answers `401` without any environment at all; | |
| 14 | +//! - the deprecated `new` path still answers `500` in the same situation | |
| 15 | +//! (historical behaviour, RFC 0022), and still honours an | |
| 16 | +//! `Extension(Arc<AuthConfig>)` posed *outside* the middleware. | |
| 17 | +//! | |
| 18 | +//! Plus an `#[ignore]`d micro-bench (RFC 0042 testing plan #16). | |
| 19 | + | |
| 20 | +#![allow(deprecated)] | |
| 21 | + | |
| 22 | +use std::sync::Arc; | |
| 23 | +use std::time::Instant; | |
| 24 | + | |
| 25 | +use axum::body::Body; | |
| 26 | +use axum::http::{Request, StatusCode, header::LOCATION}; | |
| 27 | +use axum::response::Response; | |
| 28 | +use axum::{Extension, Router, routing::get}; | |
| 29 | +use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; | |
| 30 | +use rustwarden_core::middleware::admin_service::AdminService; | |
| 31 | +use rustwarden_core::middleware::auth_service::AuthService; | |
| 32 | +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; | |
| 33 | +use tower::ServiceExt; // for `oneshot` | |
| 34 | + | |
| 35 | +fn set_minimal_env() { | |
| 36 | + // SAFETY: every caller is a `#[serial_test::serial(env)]` test, so no | |
| 37 | + // other thread reads or writes the environment concurrently. | |
| 38 | + unsafe { | |
| 39 | + std::env::set_var( | |
| 40 | + "JWT_SECRET", | |
| 41 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | |
| 42 | + ); | |
| 43 | + std::env::set_var("APP_DEBUG", "true"); | |
| 44 | + std::env::remove_var("AUTH_FAILURE_MODE"); | |
| 45 | + std::env::remove_var("LOGIN_PATH"); | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +fn clear_env() { | |
| 50 | + // SAFETY: see set_minimal_env. | |
| 51 | + unsafe { | |
| 52 | + std::env::remove_var("JWT_SECRET"); | |
| 53 | + std::env::remove_var("APP_DEBUG"); | |
| 54 | + std::env::remove_var("AUTH_FAILURE_MODE"); | |
| 55 | + std::env::remove_var("LOGIN_PATH"); | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +fn injected_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { | |
| 60 | + AuthConfig { | |
| 61 | + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), | |
| 62 | + jwt_expiration_minutes: 15, | |
| 63 | + jwt_issuer: "test".to_string(), | |
| 64 | + refresh_token_expiration_days: 7, | |
| 65 | + remember_me_expiration_days: 30, | |
| 66 | + session_timeout_minutes: 30, | |
| 67 | + session_backend: SessionBackend::Memory, | |
| 68 | + redis_url: None, | |
| 69 | + rate_limit_login_per_minute: 5, | |
| 70 | + rate_limit_refresh_per_minute: 10, | |
| 71 | + rate_limit_general_per_minute: 100, | |
| 72 | + cookie_secure: false, | |
| 73 | + cookie_same_site: "Strict".to_string(), | |
| 74 | + login_path: login_path.to_string(), | |
| 75 | + auth_failure_mode: mode, | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +fn mock_db() -> DatabaseConnection { | |
| 80 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection() | |
| 81 | +} | |
| 82 | + | |
| 83 | +fn prefixes() -> Arc<Vec<String>> { | |
| 84 | + Arc::new(vec!["/private".to_string()]) | |
| 85 | +} | |
| 86 | + | |
| 87 | +fn routes() -> Router { | |
| 88 | + Router::new() | |
| 89 | + .route("/private", get(|| async { "secret" })) | |
| 90 | + .route("/admin", get(|| async { "admin" })) | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Canonical 1.2.0 wiring: both middlewares carry the resolved config. | |
| 94 | +fn stack_with_auth_config(cfg: AuthConfig) -> Router { | |
| 95 | + let cfg = Arc::new(cfg); | |
| 96 | + let (db_admin, cfg_admin) = (mock_db(), cfg.clone()); | |
| 97 | + let (db_auth, cfg_auth, p) = (mock_db(), cfg, prefixes()); | |
| 98 | + routes() | |
| 99 | + .layer(tower::layer::layer_fn(move |inner| { | |
| 100 | + AuthService::with_auth_config(inner, db_auth.clone(), p.clone(), cfg_auth.clone()) | |
| 101 | + })) | |
| 102 | + .layer(tower::layer::layer_fn(move |inner| { | |
| 103 | + AdminService::with_auth_config(inner, db_admin.clone(), cfg_admin.clone()) | |
| 104 | + })) | |
| 105 | +} | |
| 106 | + | |
| 107 | +/// Legacy wiring (deprecated `new`): no captured config. | |
| 108 | +fn stack_legacy() -> Router { | |
| 109 | + let (db_admin, db_auth, p) = (mock_db(), mock_db(), prefixes()); | |
| 110 | + routes() | |
| 111 | + .layer(tower::layer::layer_fn(move |inner| { | |
| 112 | + AuthService::new(inner, db_auth.clone(), p.clone()) | |
| 113 | + })) | |
| 114 | + .layer(tower::layer::layer_fn(move |inner| { | |
| 115 | + AdminService::new(inner, db_admin.clone()) | |
| 116 | + })) | |
| 117 | +} | |
| 118 | + | |
| 119 | +async fn get_path(router: Router, path: &str) -> Response { | |
| 120 | + let req = Request::builder() | |
| 121 | + .uri(path) | |
| 122 | + .body(Body::empty()) | |
| 123 | + .expect("request"); | |
| 124 | + router.oneshot(req).await.expect("oneshot") | |
| 125 | +} | |
| 126 | + | |
| 127 | +fn location(resp: &Response) -> Option<&str> { | |
| 128 | + resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) | |
| 129 | +} | |
| 130 | + | |
| 131 | +#[tokio::test] | |
| 132 | +#[serial_test::serial(env)] | |
| 133 | +async fn with_auth_config_answers_without_any_environment() { | |
| 134 | + clear_env(); | |
| 135 | + assert!( | |
| 136 | + AuthConfig::from_env().is_err(), | |
| 137 | + "precondition: no JWT_SECRET in the environment" | |
| 138 | + ); | |
| 139 | + | |
| 140 | + let cfg = injected_config(AuthFailureMode::Json401, "/login"); | |
| 141 | + let resp = get_path(stack_with_auth_config(cfg), "/private").await; | |
| 142 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 143 | + | |
| 144 | + let cfg = injected_config(AuthFailureMode::Json401, "/login"); | |
| 145 | + let resp = get_path(stack_with_auth_config(cfg), "/admin").await; | |
| 146 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 147 | +} | |
| 148 | + | |
| 149 | +#[tokio::test] | |
| 150 | +#[serial_test::serial(env)] | |
| 151 | +async fn with_auth_config_honours_a_custom_login_path_without_environment() { | |
| 152 | + clear_env(); | |
| 153 | + | |
| 154 | + let cfg = injected_config(AuthFailureMode::Redirect, "/auth/signin"); | |
| 155 | + let resp = get_path(stack_with_auth_config(cfg), "/private").await; | |
| 156 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 157 | + assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate")); | |
| 158 | +} | |
| 159 | + | |
| 160 | +#[tokio::test] | |
| 161 | +#[serial_test::serial(env)] | |
| 162 | +async fn legacy_new_without_extension_nor_environment_answers_500() { | |
| 163 | + clear_env(); | |
| 164 | + | |
| 165 | + let resp = get_path(stack_legacy(), "/private").await; | |
| 166 | + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); | |
| 167 | + | |
| 168 | + let resp = get_path(stack_legacy(), "/admin").await; | |
| 169 | + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); | |
| 170 | +} | |
| 171 | + | |
| 172 | +#[tokio::test] | |
| 173 | +#[serial_test::serial(env)] | |
| 174 | +async fn legacy_new_uses_an_extension_posed_outside_the_middleware() { | |
| 175 | + clear_env(); | |
| 176 | + | |
| 177 | + // `Router::layer` after the middlewares = outermost = visible to them. | |
| 178 | + let cfg = Arc::new(injected_config(AuthFailureMode::Redirect, "/auth/signin")); | |
| 179 | + let resp = get_path(stack_legacy().layer(Extension(cfg)), "/private").await; | |
| 180 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 181 | + assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate")); | |
| 182 | +} | |
| 183 | + | |
| 184 | +#[tokio::test] | |
| 185 | +#[serial_test::serial(env)] | |
| 186 | +async fn legacy_new_falls_back_to_the_environment_and_ignores_overrides() { | |
| 187 | + set_minimal_env(); | |
| 188 | + | |
| 189 | + // No extension, env has no AUTH_FAILURE_MODE → from_env() default: Redirect to /login. | |
| 190 | + let resp = get_path(stack_legacy(), "/private").await; | |
| 191 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 192 | + assert_eq!(location(&resp), Some("/login?redirect=%2Fprivate")); | |
| 193 | + | |
| 194 | + clear_env(); | |
| 195 | +} | |
| 196 | + | |
| 197 | +/// RFC 0042 testing plan #16 — micro-bench, run with | |
| 198 | +/// `cargo test -p rustwarden-core --test auth_service_resolved_config -- --ignored --nocapture bench`. | |
| 199 | +#[tokio::test] | |
| 200 | +#[serial_test::serial(env)] | |
| 201 | +#[ignore = "micro-bench, run explicitly with --ignored --nocapture"] | |
| 202 | +async fn bench_from_env_versus_captured_config() { | |
| 203 | + set_minimal_env(); | |
| 204 | + const N: u32 = 10_000; | |
| 205 | + | |
| 206 | + let start = Instant::now(); | |
| 207 | + for _ in 0..N { | |
| 208 | + let _ = AuthConfig::from_env(); | |
| 209 | + } | |
| 210 | + let from_env = start.elapsed(); | |
| 211 | + | |
| 212 | + let start = Instant::now(); | |
| 213 | + for _ in 0..N { | |
| 214 | + let resp = get_path(stack_legacy(), "/private").await; | |
| 215 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 216 | + } | |
| 217 | + let legacy = start.elapsed(); | |
| 218 | + | |
| 219 | + let start = Instant::now(); | |
| 220 | + for _ in 0..N { | |
| 221 | + let cfg = injected_config(AuthFailureMode::Redirect, "/login"); | |
| 222 | + let resp = get_path(stack_with_auth_config(cfg), "/private").await; | |
| 223 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 224 | + } | |
| 225 | + let captured = start.elapsed(); | |
| 226 | + | |
| 227 | + println!( | |
| 228 | + "bench RFC 0042 (N={N}): from_env alone = {:?} ({:?}/call); legacy stack = {:?} ({:?}/req); with_auth_config stack = {:?} ({:?}/req)", | |
| 229 | + from_env, | |
| 230 | + from_env / N, | |
| 231 | + legacy, | |
| 232 | + legacy / N, | |
| 233 | + captured, | |
| 234 | + captured / N, | |
| 235 | + ); | |
| 236 | + clear_env(); | |
| 237 | +} |
M
crates/rustwarden-fediverse-guard/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-fediverse-guard" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Real-time abuse detection and ban management for the Rustwarden ActivityPub federation layer (RFC 0028+)" |
M
crates/rustwarden-fediverse-ui/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-fediverse-ui" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Reference HTML UI for Rustwarden ActivityPub federation — handlers, templates, dashboard card" |
M
crates/rustwarden-fediverse/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-fediverse" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "ActivityPub federation for Rustwarden — WebFinger, actor, inbox, HTTP signatures, AP-native auth, cross-instance team invitations" |
M
crates/rustwarden-ui/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-ui" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Default UI for Rustwarden – HTML templates, handlers, admin panel" |
M
crates/rustwarden-ui/src/handlers/root_handler.rs
+127
-29
@@ -2,77 +2,175 @@
| 2 | 2 | /// Feature 004: UI Post-Auth |
| 3 | 3 | /// |
| 4 | 4 | /// Handles the root path `/` with smart redirection based on authentication status. |
| 5 | +use std::sync::Arc; | |
| 6 | + | |
| 5 | 7 | use axum::{ |
| 6 | 8 | extract::Request, |
| 7 | 9 | response::{IntoResponse, Redirect}, |
| 8 | 10 | }; |
| 11 | +use rustwarden_core::config::auth::AuthConfig; | |
| 9 | 12 | |
| 10 | 13 | /// Handler for GET `/` - Root path with smart redirection |
| 11 | 14 | /// |
| 12 | 15 | /// Redirects based on authentication status: |
| 13 | 16 | /// - If authenticated → redirects to `/home` |
| 14 | -/// - If not authenticated → redirects to `/login` | |
| 17 | +/// - If not authenticated → redirects to the configured login path | |
| 18 | +/// ([`AuthConfig::login_path`], default `/login`) | |
| 15 | 19 | /// |
| 16 | 20 | /// This provides a seamless entry point for users. |
| 17 | 21 | /// |
| 22 | +/// The [`AuthConfig`] is read from the request extensions, where | |
| 23 | +/// `RustwardenBuilder::build()` injects the configuration resolved at | |
| 24 | +/// startup (including builder overrides such as `login_path`, RFC 0042). | |
| 25 | +/// `AuthConfig::from_env()` is only a defence for stacks that do not pose | |
| 26 | +/// the extension. | |
| 27 | +/// | |
| 18 | 28 | /// # Arguments |
| 19 | 29 | /// |
| 20 | 30 | /// * `request` - HTTP request to check for JWT token |
| 21 | 31 | /// |
| 22 | 32 | /// # Returns |
| 23 | 33 | /// |
| 24 | -/// * `302 Found` redirect to `/home` or `/login` | |
| 34 | +/// * `303 See Other` redirect to `/home` or to the login path | |
| 25 | 35 | /// |
| 26 | 36 | /// # Example |
| 27 | 37 | /// |
| 28 | 38 | /// ```bash |
| 29 | 39 | /// # Without authentication |
| 30 | 40 | /// curl -I http://localhost:3000/ |
| 31 | -/// # → 302 Redirect to /login | |
| 41 | +/// # → 303 Redirect to /login | |
| 32 | 42 | /// |
| 33 | 43 | /// # With valid JWT token |
| 34 | 44 | /// curl -I http://localhost:3000/ -H "Cookie: jwt_token=..." |
| 35 | -/// # → 302 Redirect to /home | |
| 45 | +/// # → 303 Redirect to /home | |
| 36 | 46 | /// ``` |
| 37 | 47 | pub async fn root_redirect(request: Request) -> impl IntoResponse { |
| 48 | + use rustwarden_core::auth::jwt::JwtService; | |
| 49 | + | |
| 50 | + // Prefer the config resolved at build time; keep `from_env()` as a | |
| 51 | + // defence for stacks that do not pose the extension. | |
| 52 | + let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() { | |
| 53 | + Some(config) => config, | |
| 54 | + None => match AuthConfig::from_env() { | |
| 55 | + Ok(config) => Arc::new(config), | |
| 56 | + Err(_) => { | |
| 57 | + // If config can't be loaded, redirect to login as fallback | |
| 58 | + tracing::warn!("root_redirect: failed to load auth config, redirecting to /login"); | |
| 59 | + return Redirect::to("/login"); | |
| 60 | + } | |
| 61 | + }, | |
| 62 | + }; | |
| 63 | + let login_path = auth_config.login_path.as_str(); | |
| 64 | + | |
| 38 | 65 | // Try to extract JWT token from request |
| 39 | 66 | match rustwarden_core::auth::jwt_extractor::extract_jwt_from_request(&request) { |
| 40 | 67 | Ok(jwt_token) => { |
| 41 | - // Token found, validate it | |
| 42 | - use rustwarden_core::auth::jwt::JwtService; | |
| 43 | - use rustwarden_core::config::auth::AuthConfig; | |
| 44 | - | |
| 45 | - // Load auth config and validate token | |
| 46 | - // Note: We validate the token without checking blacklist for now | |
| 47 | - // The token will be re-validated on protected routes anyway | |
| 48 | - let auth_config = match AuthConfig::from_env() { | |
| 49 | - Ok(config) => config, | |
| 50 | - Err(_) => { | |
| 51 | - // If config can't be loaded, redirect to login as fallback | |
| 52 | - tracing::warn!( | |
| 53 | - "root_redirect: failed to load auth config, redirecting to /login" | |
| 54 | - ); | |
| 55 | - return Redirect::to("/login"); | |
| 56 | - } | |
| 57 | - }; | |
| 58 | - | |
| 68 | + // Validate the token without checking the blacklist: it is | |
| 69 | + // re-validated on protected routes anyway. | |
| 59 | 70 | match JwtService::validate_token(&auth_config, &jwt_token) { |
| 60 | 71 | Ok(_claims) => { |
| 61 | - // Token is valid, redirect to home | |
| 62 | 72 | tracing::debug!("root_redirect: valid JWT token found, redirecting to /home"); |
| 63 | 73 | Redirect::to("/home") |
| 64 | 74 | } |
| 65 | 75 | Err(_) => { |
| 66 | - // Token is invalid, redirect to login | |
| 67 | - tracing::debug!("root_redirect: invalid JWT token, redirecting to /login"); | |
| 68 | - Redirect::to("/login") | |
| 76 | + tracing::debug!( | |
| 77 | + "root_redirect: invalid JWT token, redirecting to {login_path}" | |
| 78 | + ); | |
| 79 | + Redirect::to(login_path) | |
| 69 | 80 | } |
| 70 | 81 | } |
| 71 | 82 | } |
| 72 | 83 | Err(_) => { |
| 73 | - // No token found, redirect to login | |
| 74 | - tracing::debug!("root_redirect: no JWT token found, redirecting to /login"); | |
| 75 | - Redirect::to("/login") | |
| 84 | + tracing::debug!("root_redirect: no JWT token found, redirecting to {login_path}"); | |
| 85 | + Redirect::to(login_path) | |
| 86 | + } | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +#[cfg(test)] | |
| 91 | +mod tests { | |
| 92 | + use super::root_redirect; | |
| 93 | + use axum::body::Body; | |
| 94 | + use axum::http::{Request as HttpRequest, StatusCode, header::LOCATION}; | |
| 95 | + use axum::{Extension, Router, routing::get}; | |
| 96 | + use rustwarden_core::auth::jwt::JwtService; | |
| 97 | + use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; | |
| 98 | + use std::sync::Arc; | |
| 99 | + use tower::ServiceExt; // for `oneshot` | |
| 100 | + | |
| 101 | + /// A `login_path` that cannot come from the environment of the test | |
| 102 | + /// process, so a redirect to it proves the extension was used. | |
| 103 | + const INJECTED_LOGIN_PATH: &str = "/auth/signin"; | |
| 104 | + | |
| 105 | + fn injected_config() -> AuthConfig { | |
| 106 | + AuthConfig { | |
| 107 | + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), | |
| 108 | + jwt_expiration_minutes: 15, | |
| 109 | + jwt_issuer: "test".to_string(), | |
| 110 | + refresh_token_expiration_days: 7, | |
| 111 | + remember_me_expiration_days: 30, | |
| 112 | + session_timeout_minutes: 30, | |
| 113 | + session_backend: SessionBackend::Memory, | |
| 114 | + redis_url: None, | |
| 115 | + rate_limit_login_per_minute: 5, | |
| 116 | + rate_limit_refresh_per_minute: 10, | |
| 117 | + rate_limit_general_per_minute: 100, | |
| 118 | + cookie_secure: false, | |
| 119 | + cookie_same_site: "Strict".to_string(), | |
| 120 | + login_path: INJECTED_LOGIN_PATH.to_string(), | |
| 121 | + auth_failure_mode: AuthFailureMode::Redirect, | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + fn router_with_injected_config() -> Router { | |
| 126 | + Router::new() | |
| 127 | + .route("/", get(root_redirect)) | |
| 128 | + .layer(Extension(Arc::new(injected_config()))) | |
| 129 | + } | |
| 130 | + | |
| 131 | + async fn location_of(router: Router, bearer: Option<&str>) -> (StatusCode, String) { | |
| 132 | + let mut builder = HttpRequest::builder().uri("/"); | |
| 133 | + if let Some(token) = bearer { | |
| 134 | + builder = builder.header("authorization", format!("Bearer {token}")); | |
| 76 | 135 | } |
| 136 | + let req = builder.body(Body::empty()).expect("request"); | |
| 137 | + let resp = router.oneshot(req).await.expect("oneshot"); | |
| 138 | + let location = resp | |
| 139 | + .headers() | |
| 140 | + .get(LOCATION) | |
| 141 | + .and_then(|v| v.to_str().ok()) | |
| 142 | + .unwrap_or_default() | |
| 143 | + .to_string(); | |
| 144 | + (resp.status(), location) | |
| 145 | + } | |
| 146 | + | |
| 147 | + #[tokio::test] | |
| 148 | + async fn anonymous_visitor_is_sent_to_the_injected_login_path() { | |
| 149 | + let (status, location) = location_of(router_with_injected_config(), None).await; | |
| 150 | + assert_eq!(status, StatusCode::SEE_OTHER); | |
| 151 | + assert_eq!(location, INJECTED_LOGIN_PATH); | |
| 152 | + } | |
| 153 | + | |
| 154 | + #[tokio::test] | |
| 155 | + async fn invalid_token_is_sent_to_the_injected_login_path() { | |
| 156 | + let (status, location) = | |
| 157 | + location_of(router_with_injected_config(), Some("not-a-jwt")).await; | |
| 158 | + assert_eq!(status, StatusCode::SEE_OTHER); | |
| 159 | + assert_eq!(location, INJECTED_LOGIN_PATH); | |
| 160 | + } | |
| 161 | + | |
| 162 | + #[tokio::test] | |
| 163 | + async fn valid_token_signed_with_the_injected_secret_goes_home() { | |
| 164 | + let token = JwtService::generate_token( | |
| 165 | + &injected_config(), | |
| 166 | + uuid::Uuid::new_v4(), | |
| 167 | + "alice", | |
| 168 | + vec!["user".to_string()], | |
| 169 | + Vec::new(), | |
| 170 | + ) | |
| 171 | + .expect("token"); | |
| 172 | + let (status, location) = location_of(router_with_injected_config(), Some(&token)).await; | |
| 173 | + assert_eq!(status, StatusCode::SEE_OTHER); | |
| 174 | + assert_eq!(location, "/home"); | |
| 77 | 175 | } |
| 78 | 176 | } |
M
docs/SERVICES_REFERENCE.md
+5
-1
@@ -1332,7 +1332,11 @@ L'auth middleware ne protège que les **préfixes enregistrés**. **Depuis 2026-
| 1332 | 1332 | |
| 1333 | 1333 | Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthConfig.auth_failure_mode` : |
| 1334 | 1334 | - `Redirect` (défaut UI) : `303` vers `login_path` (défaut `/login`, env `LOGIN_PATH` ou builder `.login_path("/…")`). |
| 1335 | -- `Json401` : `401 {"error":"unauthorized"}`. **`.headless()` bascule le défaut sur `Json401`** ; override explicite via `.auth_failure_mode(AuthFailureMode::…)` (env `AUTH_FAILURE_MODE=redirect|json401`). | |
| 1335 | +- `Json401` : `401 {"error":"unauthorized"}`. **`.headless()` bascule le défaut sur `Json401`**. | |
| 1336 | + | |
| 1337 | +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`. | |
| 1338 | + | |
| 1339 | +> ⚠ **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`](auth-middleware-layering.md). | |
| 1336 | 1340 | |
| 1337 | 1341 | #### Router auth JSON prêt à monter |
| 1338 | 1342 |
M
docs/UPSTREAM_RUSTWARDEN.md
+125
-592
@@ -1,592 +1,125 @@
| 1 | -# Notes sur l'utilisation de RustWarden-core dans Gitrust | |
| 2 | - | |
| 3 | -Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core. | |
| 4 | - | |
| 5 | -## Services framework utilises | |
| 6 | - | |
| 7 | -| Service | Usage dans Gitrust | | |
| 8 | -|---------|-------------------| | |
| 9 | -| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` | | |
| 10 | -| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` | | |
| 11 | -| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` | | |
| 12 | -| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` | | |
| 13 | -| `AuthService` | `authenticate_user` (login) | | |
| 14 | -| `JwtService` | `generate_token` (login) | | |
| 15 | -| `EmailValidationService` | Verification email | | |
| 16 | -| `PasswordResetService` | Flux "mot de passe oublie" | | |
| 17 | -| `AppSettingsService` | `get_bool` pour `registration_enabled` | | |
| 18 | - | |
| 19 | -## Hook `on_user_deleted` implemente | |
| 20 | - | |
| 21 | -Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`. | |
| 22 | - | |
| 23 | -## Aucun contournement en cours | |
| 24 | - | |
| 25 | -Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26. | |
| 26 | - | |
| 27 | ---- | |
| 28 | - | |
| 29 | -## Propositions d'amelioration framework (backlog upstream) | |
| 30 | - | |
| 31 | -Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream. | |
| 32 | - | |
| 33 | -### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22 | |
| 34 | - | |
| 35 | -**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 36 | - | |
| 37 | -Proposition A retenue : | |
| 38 | -- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB). | |
| 39 | -- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body. | |
| 40 | -- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B). | |
| 41 | -- Multipart skip, body > cap → 413, 11 tests TDD. | |
| 42 | -- `csrf_protection` existante inchangee (backwards-compat). | |
| 43 | - | |
| 44 | ---- | |
| 45 | - | |
| 46 | -### 1bis. Details historiques (avant resolution) | |
| 47 | - | |
| 48 | -**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard). | |
| 49 | - | |
| 50 | -**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`. | |
| 51 | - | |
| 52 | -**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement : | |
| 53 | - | |
| 54 | -```rust | |
| 55 | -// For form data, we would need to parse the body | |
| 56 | -// This is handled in handlers that process form data | |
| 57 | -``` | |
| 58 | - | |
| 59 | -Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de : | |
| 60 | -- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence), | |
| 61 | -- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate). | |
| 62 | - | |
| 63 | -**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui : | |
| 64 | -1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification. | |
| 65 | -2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable). | |
| 66 | -3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch). | |
| 67 | -4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`. | |
| 68 | - | |
| 69 | -Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust. | |
| 70 | - | |
| 71 | -**Propositions d'amelioration** (par ordre de preference) : | |
| 72 | - | |
| 73 | -#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body` | |
| 74 | - | |
| 75 | -Ajouter un parametre optionnel au middleware pour activer la lecture du body : | |
| 76 | - | |
| 77 | -```rust | |
| 78 | -// Nouveau dans rustwarden_core::middleware::csrf | |
| 79 | -pub struct CsrfProtectionConfig { | |
| 80 | - /// Si true, lit le champ `csrf_token` du body form-urlencoded | |
| 81 | - /// quand le header `X-CSRF-Token` est absent. Bufferise le body | |
| 82 | - /// en RAM (attention aux uploads multipart — ignores). | |
| 83 | - /// Defaut : false (compatibilite avec le comportement existant). | |
| 84 | - pub accept_form_body: bool, | |
| 85 | - /// Taille max du body bufferise. Defaut : 1 MiB. | |
| 86 | - pub max_form_body_bytes: usize, | |
| 87 | -} | |
| 88 | - | |
| 89 | -impl Default for CsrfProtectionConfig { | |
| 90 | - fn default() -> Self { | |
| 91 | - Self { | |
| 92 | - accept_form_body: false, | |
| 93 | - max_form_body_bytes: 1024 * 1024, | |
| 94 | - } | |
| 95 | - } | |
| 96 | -} | |
| 97 | - | |
| 98 | -/// Nouveau constructeur pour apps SSR. | |
| 99 | -pub async fn csrf_protection_with_config( | |
| 100 | - State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>, | |
| 101 | - session: Session, | |
| 102 | - uri: Uri, | |
| 103 | - request: Request, | |
| 104 | - next: Next, | |
| 105 | -) -> Result<Response, AppError> { | |
| 106 | - // ... flow existant + branche buffer-body si cfg.accept_form_body | |
| 107 | -} | |
| 108 | -``` | |
| 109 | - | |
| 110 | -**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework. | |
| 111 | -**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered). | |
| 112 | - | |
| 113 | -#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>` | |
| 114 | - | |
| 115 | -Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware : | |
| 116 | - | |
| 117 | -```rust | |
| 118 | -// Nouveau dans rustwarden_core::middleware::csrf | |
| 119 | -/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded` | |
| 120 | -/// URL-encode. Retourne `None` si le champ est absent ou le body malforme. | |
| 121 | -pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... } | |
| 122 | -``` | |
| 123 | - | |
| 124 | -**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques. | |
| 125 | -**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle. | |
| 126 | - | |
| 127 | -#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie | |
| 128 | - | |
| 129 | -```rust | |
| 130 | -// Dans rustwarden_core (derriere feature = "ssr-forms") | |
| 131 | -pub async fn csrf_protection_ssr( | |
| 132 | - State(db): State<DatabaseConnection>, | |
| 133 | - session: Session, | |
| 134 | - uri: Uri, | |
| 135 | - request: Request, | |
| 136 | - next: Next, | |
| 137 | -) -> Result<Response, AppError> { | |
| 138 | - // Flow complet avec buffer-body + validation | |
| 139 | -} | |
| 140 | -``` | |
| 141 | - | |
| 142 | -**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite. | |
| 143 | -**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature. | |
| 144 | - | |
| 145 | -**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee. | |
| 146 | - | |
| 147 | -**Criteres d'acceptation** pour le PR upstream : | |
| 148 | -- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat). | |
| 149 | -- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`. | |
| 150 | -- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body. | |
| 151 | -- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser). | |
| 152 | -- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement. | |
| 153 | -- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413. | |
| 154 | -- [ ] Doc inline precisant la trade-off buffer vs streaming. | |
| 155 | - | |
| 156 | -**Migration cote gitrust apres merge upstream** : | |
| 157 | -1. Bump version rustwarden-core dans `Cargo.toml`. | |
| 158 | -2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests. | |
| 159 | -3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`. | |
| 160 | -4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu"). | |
| 161 | - | |
| 162 | -### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22 | |
| 163 | - | |
| 164 | -**Status** : Implemente en Phase 1.1 du plan d'evolution. | |
| 165 | - | |
| 166 | -Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee. | |
| 167 | - | |
| 168 | ---- | |
| 169 | - | |
| 170 | -### 2bis. Details historiques (avant resolution) | |
| 171 | - | |
| 172 | -**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client. | |
| 173 | - | |
| 174 | -**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`. | |
| 175 | - | |
| 176 | -**Proposition** : ajouter un constructeur async qui fait le fetch du token : | |
| 177 | - | |
| 178 | -```rust | |
| 179 | -impl LayoutContext { | |
| 180 | - /// Version async qui peuple `csrf_token` depuis la session tower-sessions. | |
| 181 | - /// Fallback silencieux sur `""` si la session est indisponible (log warn). | |
| 182 | - pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self { | |
| 183 | - let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session) | |
| 184 | - .await | |
| 185 | - .unwrap_or_default(); | |
| 186 | - Self::new(title, &token) | |
| 187 | - } | |
| 188 | -} | |
| 189 | -``` | |
| 190 | - | |
| 191 | -**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues. | |
| 192 | -**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`. | |
| 193 | - | |
| 194 | -**Criteres d'acceptation** : | |
| 195 | -- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat). | |
| 196 | -- [ ] Nouveau `from_session` disponible, teste avec une session mock. | |
| 197 | -- [ ] Doc inline avec exemple de handler qui l'utilise. | |
| 198 | - | |
| 199 | -**Migration cote gitrust apres merge** : | |
| 200 | -1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`. | |
| 201 | -2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant. | |
| 202 | - | |
| 203 | -### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22 | |
| 204 | - | |
| 205 | -**Status** : Implemente en Phase 1.2 du plan d'evolution. | |
| 206 | - | |
| 207 | -Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees. | |
| 208 | - | |
| 209 | ---- | |
| 210 | - | |
| 211 | -### 3bis. Details historiques (avant resolution) | |
| 212 | - | |
| 213 | -**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`. | |
| 214 | - | |
| 215 | -**Proposition** : deduplication via une fonction interne commune + 2 wrappers : | |
| 216 | -```rust | |
| 217 | -async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... } | |
| 218 | -pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... } | |
| 219 | -pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... } | |
| 220 | -``` | |
| 221 | - | |
| 222 | -**Priorite** : basse (DRY seulement, pas de bug). | |
| 223 | - | |
| 224 | -### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23 | |
| 225 | - | |
| 226 | -**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 227 | - | |
| 228 | -Livre : | |
| 229 | -- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`). | |
| 230 | -- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`. | |
| 231 | -- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check. | |
| 232 | -- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s). | |
| 233 | -- Prelude exporte `PersonalTokenService` et `TokenScope`. | |
| 234 | -- 15 tests (5 entity + 10 integration DB). | |
| 235 | - | |
| 236 | ---- | |
| 237 | - | |
| 238 | -### 4bis. Details historiques (avant resolution) | |
| 239 | - | |
| 240 | -**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11). | |
| 241 | - | |
| 242 | -**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel. | |
| 243 | - | |
| 244 | -**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre : | |
| 245 | -- Token SHA-256 en base, verifie a chaque appel (pas de rotation). | |
| 246 | -- Scopes structures (`read:repo`, `write:repo`, `admin:repo`). | |
| 247 | -- `last_used_at` pour UX et nettoyage. | |
| 248 | -- Expiration optionnelle (`expires_at NULL` = token permanent). | |
| 249 | - | |
| 250 | -**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee : | |
| 251 | -- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at). | |
| 252 | -- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond). | |
| 253 | -- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`. | |
| 254 | -- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust). | |
| 255 | - | |
| 256 | -**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur. | |
| 257 | - | |
| 258 | -```rust | |
| 259 | -pub trait TokenScope: Sized + Clone + Send + Sync + 'static { | |
| 260 | - fn as_str(&self) -> &str; | |
| 261 | - fn parse(s: &str) -> Option<Self>; | |
| 262 | -} | |
| 263 | - | |
| 264 | -pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> } | |
| 265 | - | |
| 266 | -impl<S: TokenScope> PersonalTokenService<S> { | |
| 267 | - pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>; | |
| 268 | - pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>; | |
| 269 | - pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>; | |
| 270 | - pub async fn revoke(db, token_id, user_id) -> Result<()>; | |
| 271 | - pub async fn cleanup_expired(db) -> Result<u64>; | |
| 272 | -} | |
| 273 | -``` | |
| 274 | - | |
| 275 | -Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`. | |
| 276 | - | |
| 277 | -**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app. | |
| 278 | -**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe). | |
| 279 | - | |
| 280 | -**Criteres d'acceptation** : | |
| 281 | -- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app. | |
| 282 | -- [ ] Hash SHA-256 en base, token clair jamais stocke. | |
| 283 | -- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`. | |
| 284 | -- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent. | |
| 285 | -- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT). | |
| 286 | - | |
| 287 | -**Migration cote gitrust apres merge** : | |
| 288 | -1. Bump `rustwarden-core`. | |
| 289 | -2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`. | |
| 290 | -3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP). | |
| 291 | - | |
| 292 | -### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23 | |
| 293 | - | |
| 294 | -**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 295 | - | |
| 296 | -Livre : | |
| 297 | -- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal. | |
| 298 | -- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests. | |
| 299 | -- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests. | |
| 300 | -- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert. | |
| 301 | -- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`. | |
| 302 | - | |
| 303 | -Total : 22 tests nouveaux sur DB reelle, 0 regression. | |
| 304 | - | |
| 305 | ---- | |
| 306 | - | |
| 307 | -### 5bis. Details historiques (avant resolution) | |
| 308 | - | |
| 309 | -**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14). | |
| 310 | - | |
| 311 | -**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe. | |
| 312 | - | |
| 313 | -**Limitation du framework** : rustwarden-core n'expose que : | |
| 314 | -- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`. | |
| 315 | -- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`). | |
| 316 | - | |
| 317 | -**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger. | |
| 318 | - | |
| 319 | -**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee : | |
| 320 | -- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at). | |
| 321 | -- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present). | |
| 322 | -- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`. | |
| 323 | - | |
| 324 | -**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`. | |
| 325 | - | |
| 326 | -```rust | |
| 327 | -pub struct TeamService; | |
| 328 | - | |
| 329 | -impl TeamService { | |
| 330 | - pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>; | |
| 331 | - pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>; | |
| 332 | - pub async fn remove_member(db, team_id, user_id) -> Result<()>; | |
| 333 | - pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>; | |
| 334 | - pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>; | |
| 335 | - pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>; | |
| 336 | -} | |
| 337 | - | |
| 338 | -// Evolution de ResourceService | |
| 339 | -pub enum Principal { User(Uuid), Team(Uuid) } | |
| 340 | -impl ResourceService { | |
| 341 | - pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>; | |
| 342 | - pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>; | |
| 343 | - // Calcule max(perm_direct, max(perm_via_teams)) | |
| 344 | -} | |
| 345 | -``` | |
| 346 | - | |
| 347 | -**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit. | |
| 348 | -**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join). | |
| 349 | - | |
| 350 | -**Criteres d'acceptation** : | |
| 351 | -- [ ] Migration `teams` + `team_members` ajoutable au migrator. | |
| 352 | -- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust. | |
| 353 | -- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie). | |
| 354 | -- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous). | |
| 355 | -- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`. | |
| 356 | -- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source. | |
| 357 | - | |
| 358 | -**Migration cote gitrust apres merge** : | |
| 359 | -1. Bump `rustwarden-core`. | |
| 360 | -2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework. | |
| 361 | -3. Ajouter UI `/teams` + templates. | |
| 362 | -4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`. | |
| 363 | - | |
| 364 | -### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22 | |
| 365 | - | |
| 366 | -**Status** : Implemente en Phase 2.3 du plan d'evolution. | |
| 367 | - | |
| 368 | -Livre : | |
| 369 | -- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`. | |
| 370 | -- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON. | |
| 371 | -- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.). | |
| 372 | -- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400. | |
| 373 | - | |
| 374 | ---- | |
| 375 | - | |
| 376 | -### 6bis. Details historiques (avant resolution) | |
| 377 | - | |
| 378 | -**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`). | |
| 379 | - | |
| 380 | -**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.). | |
| 381 | - | |
| 382 | -**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont : | |
| 383 | -- **Internes** a chaque service, non reutilisables. | |
| 384 | -- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire. | |
| 385 | -- Pas d'API publique `RateLimitService::check(key, per_minute)`. | |
| 386 | - | |
| 387 | -Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login). | |
| 388 | - | |
| 389 | -**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee : | |
| 390 | -- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante. | |
| 391 | -- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`. | |
| 392 | -- Pas de persistence (reset au restart acceptable pour ce scope). | |
| 393 | - | |
| 394 | -**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding). | |
| 395 | - | |
| 396 | -```rust | |
| 397 | -pub struct RateLimitService { | |
| 398 | - buckets: Arc<DashMap<String, WindowCounter>>, | |
| 399 | -} | |
| 400 | - | |
| 401 | -impl RateLimitService { | |
| 402 | - pub fn new() -> Self; | |
| 403 | - | |
| 404 | - /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement. | |
| 405 | - pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>; | |
| 406 | - | |
| 407 | - /// Variante avec fenetre custom (ex. 1h pour reset password). | |
| 408 | - pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>; | |
| 409 | - | |
| 410 | - /// Purge periodique des buckets expires (tache de fond). | |
| 411 | - pub async fn cleanup_loop(&self, interval: Duration); | |
| 412 | -} | |
| 413 | -``` | |
| 414 | - | |
| 415 | -Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`. | |
| 416 | - | |
| 417 | -**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`). | |
| 418 | -**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1). | |
| 419 | - | |
| 420 | -**Criteres d'acceptation** : | |
| 421 | -- [ ] `RateLimitService::check(key, per_minute)` public. | |
| 422 | -- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`. | |
| 423 | -- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif). | |
| 424 | -- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes). | |
| 425 | -- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere. | |
| 426 | - | |
| 427 | -**Migration cote gitrust apres merge** : | |
| 428 | -1. Bump `rustwarden-core`. | |
| 429 | -2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement. | |
| 430 | -3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import. | |
| 431 | - | |
| 432 | -### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23 | |
| 433 | - | |
| 434 | -**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 435 | - | |
| 436 | -Livre : | |
| 437 | -- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`. | |
| 438 | -- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop. | |
| 439 | -- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables. | |
| 440 | -- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration). | |
| 441 | -- Feature `redis-bus` documentee comme extension v2 (hors scope v1). | |
| 442 | - | |
| 443 | ---- | |
| 444 | - | |
| 445 | -### 7bis. Details historiques (avant resolution) | |
| 446 | - | |
| 447 | -**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`). | |
| 448 | - | |
| 449 | -**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events : | |
| 450 | -- Barre de progression d'import de repo (`GET /imports/:id/stream`). | |
| 451 | -- Notifications CI (deja en prod cote gitrust — Phase 5). | |
| 452 | -- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR. | |
| 453 | - | |
| 454 | -**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`). | |
| 455 | - | |
| 456 | -**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`. | |
| 457 | - | |
| 458 | -**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut. | |
| 459 | - | |
| 460 | -```rust | |
| 461 | -#[async_trait] | |
| 462 | -pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync { | |
| 463 | - /// Publie a tous les abonnes matchant le filtre. | |
| 464 | - async fn publish(&self, event: T); | |
| 465 | - | |
| 466 | - /// Souscrit avec filtre. Retourne un flux tokio-stream. | |
| 467 | - fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static) | |
| 468 | - -> Pin<Box<dyn Stream<Item = T> + Send>>; | |
| 469 | -} | |
| 470 | - | |
| 471 | -pub struct InMemoryBus<T> { tx: broadcast::Sender<T> } | |
| 472 | -impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... } | |
| 473 | - | |
| 474 | -// Helper axum pour convertir une souscription en reponse SSE | |
| 475 | -pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>; | |
| 476 | -``` | |
| 477 | - | |
| 478 | -Feature optionnelle `redis-bus` pour impl multi-instance. | |
| 479 | - | |
| 480 | -**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete). | |
| 481 | -**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep. | |
| 482 | - | |
| 483 | -**Criteres d'acceptation** : | |
| 484 | -- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`. | |
| 485 | -- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut). | |
| 486 | -- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher. | |
| 487 | -- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`). | |
| 488 | -- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`). | |
| 489 | - | |
| 490 | -**Migration cote gitrust apres merge** : | |
| 491 | -1. Bump `rustwarden-core`. | |
| 492 | -2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework. | |
| 493 | -3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`. | |
| 494 | -4. Publier `ImportProgress` sur le bus depuis `import_worker`. | |
| 495 | - | |
| 496 | -### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22 | |
| 497 | - | |
| 498 | -**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 499 | - | |
| 500 | -Ajoutes avec impl par defaut no-op (backwards-compatible) : | |
| 501 | -- `on_resource_registered(db, resource_type, resource_id, owner_id)` | |
| 502 | -- `on_resource_unregistered(db, resource_type, resource_id)` | |
| 503 | -- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)` | |
| 504 | -- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)` | |
| 505 | -- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique | |
| 506 | - | |
| 507 | -`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests). | |
| 508 | - | |
| 509 | ---- | |
| 510 | - | |
| 511 | -### 8bis. Details historiques (avant resolution) | |
| 512 | - | |
| 513 | -**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR). | |
| 514 | -**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques. | |
| 515 | - | |
| 516 | -**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements : | |
| 517 | -- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted` | |
| 518 | -- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login` | |
| 519 | -- RBAC : `on_role_changed` | |
| 520 | -- Resources : `on_resource_shared`, `on_resource_unshared` | |
| 521 | - | |
| 522 | -**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent : | |
| 523 | -- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service. | |
| 524 | -- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5). | |
| 525 | -- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook). | |
| 526 | - | |
| 527 | -**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs). | |
| 528 | - | |
| 529 | -**Workaround actuel cote gitrust** : | |
| 530 | -- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add). | |
| 531 | -- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants). | |
| 532 | - | |
| 533 | -**Proposition upstream** : evoluer `RustwardenHooks` en deux temps. | |
| 534 | - | |
| 535 | -**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) : | |
| 536 | - | |
| 537 | -```rust | |
| 538 | -#[async_trait] | |
| 539 | -pub trait RustwardenHooks: Send + Sync { | |
| 540 | - // Existants | |
| 541 | - async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) } | |
| 542 | - async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 543 | - async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) } | |
| 544 | - async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) } | |
| 545 | - | |
| 546 | - // Nouveaux | |
| 547 | - async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) } | |
| 548 | - async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) } | |
| 549 | - async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) } | |
| 550 | - async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) } | |
| 551 | - async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 552 | - async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 553 | - | |
| 554 | - // Generique (escape hatch pour evenements custom) | |
| 555 | - async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) } | |
| 556 | -} | |
| 557 | - | |
| 558 | -pub struct AuditEvent<'a> { | |
| 559 | - pub kind: &'a str, // ex. "issue.created", "pr.merged" | |
| 560 | - pub actor_id: Option<Uuid>, | |
| 561 | - pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) | |
| 562 | - pub metadata: &'a serde_json::Value, | |
| 563 | -} | |
| 564 | -``` | |
| 565 | - | |
| 566 | -**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison. | |
| 567 | - | |
| 568 | -**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService). | |
| 569 | -**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework. | |
| 570 | - | |
| 571 | -**Criteres d'acceptation** : | |
| 572 | -- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif). | |
| 573 | -- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage. | |
| 574 | -- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B). | |
| 575 | -- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee. | |
| 576 | - | |
| 577 | -**Migration cote gitrust apres merge** : | |
| 578 | -1. Bump `rustwarden-core`. | |
| 579 | -2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`). | |
| 580 | -3. Implementer `on_team_*` quand LOT 14 arrive. | |
| 581 | -4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local. | |
| 582 | - | |
| 583 | ---- | |
| 584 | - | |
| 585 | -## Protocole de remontee upstream | |
| 586 | - | |
| 587 | -Quand une amelioration est mergee upstream : | |
| 588 | -1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`. | |
| 589 | -2. Supprimer la section correspondante de ce fichier. | |
| 590 | -3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version). | |
| 591 | - | |
| 592 | -Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif. | |
| 1 | +# Usage du framework Rustwarden (vendored) | |
| 2 | + | |
| 3 | +Version synchronisée : voir `.rustwarden-version` à la racine. | |
| 4 | +Resynchronisation uniquement via `update-project` (depuis le dépôt | |
| 5 | +Rustwarden) — jamais de modification manuelle de `crates/rustwarden-core/`. | |
| 6 | +Référence API : `docs/SERVICES_REFERENCE.md` (resynchronisé à chaque update). | |
| 7 | + | |
| 8 | +## Services consommés | |
| 9 | + | |
| 10 | +| Service / primitive | Usage BotPlace | | |
| 11 | +|---|---| | |
| 12 | +| Auth JWT + sessions (mode headless) | Login SSR fourni côté app ; pages protégées via `register_protected_prefix` (`/config`, `/chat` dans `src/main.rs` ; `/home`, `/profile`, `/settings`, `/admin` via `botplace_ui::with_ssr_auth_policy`) | | |
| 13 | +| `rustwarden_core::auth::auth_api_routes()` | Routeur machine canonique (`/api/v1/auth/*` : login, refresh, 2FA, reset, vérification, changement d'email, limite IP sur les routes pré-session) monté tel quel par `botplace_ui::machine_routes()` + `/health` (plan 006, tranche C) | | |
| 14 | +| Users / teams | Identités et groupes du cabinet (teams = « groupes » de la spec) | | |
| 15 | +| RBAC (rôles/permissions) | Distinction admin vs collaborateur (file des déviances, adoption) | | |
| 16 | +| `EmailChangeService::admin_force_change` | Correction d'adresse par l'admin depuis la fiche compte (`PUT /admin/users/{id}`) — audit framework `email_changed_by_admin`, sessions de la cible révoquées (plan 006, tranche D) | | |
| 17 | +| `ensure_default_admin` (bootstrap) | Création de l'admin au démarrage (seul chemin qui accepte le nom réservé `admin`) ; réutilisé par les harnais de tests (`tests/common/mod.rs`) | | |
| 18 | +| `SecurityHeadersConfig` | CSP explicite (`DEFAULT_CONTENT_SECURITY_POLICY`, avec `'unsafe-eval'`) via `botplace_ui::with_alpine_csp` — voir Workarounds | | |
| 19 | +| CSRF structurel (canal browser) | Toutes les routes HTMX du plan 001 | | |
| 20 | +| Rate limiting | Défauts du framework ; limite IP sur login/register/refresh/2FA héritée d'`auth_api_routes()` | | |
| 21 | +| Hooks `on_audit_event` | Prévu : alimentation du journal d'audit métier (tranche 6) | | |
| 22 | +| Personal Access Tokens | Prévu : authentification des instances hermes-agent (canal machine, plan ultérieur) | | |
| 23 | + | |
| 24 | +## Hooks implémentés | |
| 25 | + | |
| 26 | +`on_user_login` (`src/main.rs`, `BotplaceHooks`) : couche utilisateur, | |
| 27 | +composition scellée, provisionnement de l'instance — jamais bloquant. | |
| 28 | + | |
| 29 | +## Workarounds / besoins upstream | |
| 30 | + | |
| 31 | +Format : date, besoin, workaround appliqué, renvoi RFC upstream si le besoin | |
| 32 | +persiste. | |
| 33 | + | |
| 34 | +### 2026-10-02 — CSP par défaut sans `'unsafe-eval'` vs build Alpine standard | |
| 35 | + | |
| 36 | +- **Besoin** : les écrans de gouvernance chargent `crates/web/static/alpine.min.js` | |
| 37 | + (build Alpine 3.14.9 standard, 98 expressions inline dans 10 templates) qui | |
| 38 | + compile ses expressions par `new Function`. La CSP par défaut 1.1.0 (RFC 0039 | |
| 39 | + phase 2) retire `'unsafe-eval'` : toute l'interactivité casserait en silence. | |
| 40 | +- **Workaround** : `botplace_ui::with_alpine_csp(builder)` repose la politique | |
| 41 | + 1.0.1 (`DEFAULT_CONTENT_SECURITY_POLICY`), constante fournie par le framework | |
| 42 | + exactement pour ce retour arrière. Test : `crates/botplace-ui/tests/politique_csp.rs`. | |
| 43 | +- **Sortie** : migration vers `@alpinejs/csp` + composants `Alpine.data(..)` | |
| 44 | + (chantier à formuler). Aucune RFC upstream : le framework documente ce chemin. | |
| 45 | + | |
| 46 | +### 2026-10-02 — `.auth_failure_mode(..)` / `.login_path(..)` du builder non honorés par `AuthService`/`AdminService` | |
| 47 | + | |
| 48 | +- **Besoin** : garantir la redirection SSR vers `/login` quel que soit | |
| 49 | + l'environnement (plan 005, A2). | |
| 50 | +- **Constat** : dans `RustwardenApp::build`, les couches `AuthService` et | |
| 51 | + `AdminService` sont posées **à l'extérieur** de l'`Extension(Arc<AuthConfig>)`. | |
| 52 | + Au moment où elles s'exécutent l'extension est absente ; elles retombent sur | |
| 53 | + `AuthConfig::from_env()` (fallback assumé par un commentaire du code) et | |
| 54 | + lisent donc `AUTH_FAILURE_MODE` / `LOGIN_PATH` dans l'environnement. Les | |
| 55 | + overrides du builder ne sont honorés que par les extracteurs internes | |
| 56 | + (`auth_required`, `admin_required`). Mis en évidence le 2026-10-02 par | |
| 57 | + `prefixes_proteges.rs` (première exécution sur base disponible). | |
| 58 | +- **Workaround** : `AUTH_FAILURE_MODE=redirect` documenté dans `.env.example` | |
| 59 | + (défaut framework identique si absent) ; le builder garde l'override pour | |
| 60 | + les chemins qui le lisent ; le test fixe l'environnement à cette valeur. | |
| 61 | +- **RFC upstream à ouvrir** : `AuthService::new` / `AdminService::new` doivent | |
| 62 | + recevoir l'`AuthConfig` résolue à la construction (comme | |
| 63 | + `security_headers_middleware` capture son `Arc`), ou l'`Extension` doit être | |
| 64 | + posée plus à l'extérieur que ces couches. | |
| 65 | + | |
| 66 | +## Adaptations aux breaking changes | |
| 67 | + | |
| 68 | +### 2026-10-02 — `update-project` vers `9f3c757` (1.1.0 + RFC 0039 phase 2) | |
| 69 | + | |
| 70 | +`cargo check` vert sans modification : ruptures **comportementales** | |
| 71 | +uniquement. Détail, Gherkin et décisions : | |
| 72 | +`docs/plans/006-rustwarden-1.1-adaptation.md`. Hors `crates/rustwarden-*` : | |
| 73 | + | |
| 74 | +1. **RFC 0036 (`Username`)** — `create_user("admin")` refusé (nom réservé) : | |
| 75 | + les harnais de tests bootstrapent l'admin par `ensure_default_admin` | |
| 76 | + (`crates/{web,botplace-ui}/tests/common/mod.rs`), le nom `admin` restant | |
| 77 | + nécessaire (couche `users/admin` des fixtures, relue depuis la base par les | |
| 78 | + handlers). Formulaires `user_new`, `user_edit`, `profile` : `pattern`, | |
| 79 | + `maxlength="39"`, aide en français. | |
| 80 | +2. **1.0.1 (`AdminConfig::validate`)** — `ADMIN_PASSWORD=password` refusé sur | |
| 81 | + le chemin de création : mot de passe fort dans les tests (`admin_env()`) ; | |
| 82 | + `.env` local à corriger (secret). | |
| 83 | +3. **1.0.1 (limiteurs partagés)** — les services reset / validation partagent | |
| 84 | + un limiteur « 3 emails / heure / adresse » par processus : les scénarios de | |
| 85 | + `comptes.rs` invitent « claire » sur une adresse propre à chacun. | |
| 86 | +4. **RFC 0039 phase 2** — CSP : voir Workarounds. | |
| 87 | +5. **`auth_api_routes()` enrichi** — `machine_routes()` délègue au routeur | |
| 88 | + canonique au lieu d'une liste recopiée (qui n'avait ni limite IP sur le | |
| 89 | + login, ni routes email-change). Test : `tests/canal_machine.rs`. | |
| 90 | +6. **RFC 0041** — `update_profile` refuse toute nouvelle adresse : fiche admin → | |
| 91 | + `admin_force_change` ; profil → email en lecture seule (self-service à | |
| 92 | + formuler). Tests : `comptes.rs`. | |
| 93 | +7. **Migrations 10 et 11** (`totp_last_used_step`, `pending_email_changes`) : | |
| 94 | + jouées sur la base de dev par `cargo run --bin migrate` ; `auto_migrate(true)` | |
| 95 | + les joue au boot en production mono-instance. | |
| 96 | + | |
| 97 | +Non concernés : RFC 0037 (aucun patch TOTP local), contrainte | |
| 98 | +`chk_email_queue_email_type` (non redéfinie), hook `auth.login_blocked` (non | |
| 99 | +implémenté), pin SeaORM (déjà `2.0.0`), `ADMIN_USERNAME=admin` (bootstrap). | |
| 100 | + | |
| 101 | +### 2026-08-01 — `update-project` vers `6261a6b` | |
| 102 | + | |
| 103 | +Trois adaptations, toutes hors `crates/rustwarden-*` (voir | |
| 104 | +`docs/plans/005-rustwarden-a3-protected-prefixes-migration.md`) : | |
| 105 | + | |
| 106 | +1. **Pin SeaORM** — le core est passé de `2.0.0-rc.20` à `2.0.0` stable ; | |
| 107 | + `Cargo.toml` racine (jamais touché par `update-project`) épinglait | |
| 108 | + `=2.0.0-rc.41` → résolution de dépendances impossible. Pin aligné sur | |
| 109 | + `2.0.0`. Aucune rupture d'API métier (`cargo check` vert sans autre | |
| 110 | + modification). | |
| 111 | +2. **A3 — préfixes protégés vidés par défaut** (`with_ui` les réenregistre, | |
| 112 | + BotPlace ne l'appelle pas) : `/home`, `/profile`, `/settings` et `/admin` | |
| 113 | + seraient devenus publics. Réenregistrés via | |
| 114 | + `botplace_ui::PROTECTED_PREFIXES` / `with_ssr_auth_policy`. | |
| 115 | +3. **A2 — `.headless()` bascule `auth_failure_mode` sur `Json401`** : l'UI SSR | |
| 116 | + aurait renvoyé `401 {"error":"unauthorized"}` au lieu d'une redirection vers | |
| 117 | + `/login`. `AuthFailureMode::Redirect` imposé par le builder. Concerne aussi | |
| 118 | + `/admin`, dont `AdminService` honore désormais le même mode d'échec. | |
| 119 | + **Correctif 2026-10-02** : l'override du builder n'atteint pas les couches | |
| 120 | + `AuthService`/`AdminService` (voir Workarounds) ; le contrat réel est | |
| 121 | + `AUTH_FAILURE_MODE=redirect` dans l'environnement. | |
| 122 | + | |
| 123 | +Non-régression : `crates/botplace-ui/tests/prefixes_proteges.rs` (monte | |
| 124 | +l'application par le vrai builder ; ✅ vert le 2026-10-02 avec | |
| 125 | +`AUTH_FAILURE_MODE=redirect`). |
M
docs/auth-middleware-layering.md
+126
-87
@@ -1,27 +1,67 @@
| 1 | -# Middleware d'auth : ordre des layers et fallback `AuthConfig` | |
| 1 | +# Middleware d'auth : résolution de l'`AuthConfig` et ordre des layers | |
| 2 | 2 | |
| 3 | 3 | > Mode Diátaxis : **explanation** (comprendre la mécanique) + un mini |
| 4 | 4 | > **how-to** de troubleshooting à la fin. Référence pour les consommateurs |
| 5 | -> de `rustwarden-core` qui mappent leurs propres routes auth-required. | |
| 5 | +> de `rustwarden-core` qui mappent leurs propres routes auth-required ou | |
| 6 | +> composent leur pile tower à la main. | |
| 6 | 7 | |
| 7 | 8 | ## Pourquoi cette page existe |
| 8 | 9 | |
| 9 | -Deux RFC se sont succédé pour stabiliser le middleware | |
| 10 | -[`AuthService`](../crates/rustwarden-core/src/middleware/auth_service.rs) : | |
| 10 | +Trois RFC se sont succédé pour stabiliser les middlewares | |
| 11 | +[`AuthService`](../crates/rustwarden-core/src/middleware/auth_service.rs) et | |
| 12 | +[`AdminService`](../crates/rustwarden-core/src/middleware/admin_service.rs) : | |
| 11 | 13 | |
| 12 | 14 | - [RFC 0022 — fallback `AuthConfig::from_env()` côté branche protected](rfcs/0022-smoke-tests-regression.md) (correctif `d4d6828`). |
| 13 | 15 | - [RFC 0031 — fallback symétrique côté branche optional auth](rfcs/0031-auth-service-optional-fallback.md). |
| 16 | +- [RFC 0042 — les middlewares reçoivent l'`AuthConfig` résolue à la construction](rfcs/0042-auth-middleware-resolved-auth-config.md) (train 1.2.0). | |
| 17 | + | |
| 18 | +Les deux premières corrigeaient un symptôme : l'ordre des layers tower | |
| 19 | +configuré par défaut dans [`app.rs`](../crates/rustwarden-core/src/app.rs) | |
| 20 | +place `AdminService` et `AuthService` **avant** le layer | |
| 21 | +`Extension(Arc<AuthConfig>)`, donc au moment où ces middlewares | |
| 22 | +s'exécutent l'extension n'est pas encore dans `request.extensions()`. | |
| 23 | +Sans fallback, le résultat était soit une erreur 500 silencieuse (avant | |
| 24 | +RFC 0022) soit des `claims = None` silencieux (avant RFC 0031). | |
| 25 | + | |
| 26 | +Mais le fallback avait un effet de bord : `from_env()` ne connaît pas les | |
| 27 | +overrides du builder. `.login_path(..)`, `.auth_failure_mode(..)` et la | |
| 28 | +bascule `Json401` de `.headless()` n'atteignaient **jamais** les deux | |
| 29 | +middlewares. La RFC 0042 traite la cause : depuis 1.2.0, chaque middleware | |
| 30 | +porte l'`Arc<AuthConfig>` résolue par `RustwardenBuilder::build()`. | |
| 31 | + | |
| 32 | +## Résolution de la configuration (depuis 1.2.0) | |
| 33 | + | |
| 34 | +Les deux middlewares résolvent l'`AuthConfig` dans cet ordre : | |
| 35 | + | |
| 36 | +1. **Configuration capturée à la construction** (`with_auth_config`). C'est | |
| 37 | + la voie canonique : `build()` charge l'environnement, applique les | |
| 38 | + overrides du builder, puis passe l'`Arc` aux constructeurs. Aucune | |
| 39 | + dépendance à l'ordre des layers. | |
| 40 | +2. `Extension(Arc<AuthConfig>)` lue dans la requête — **chemin legacy**, | |
| 41 | + uniquement si le middleware a été construit avec `new` (déprécié). | |
| 42 | +3. `AuthConfig::from_env()` — chemin legacy de dernier recours, même | |
| 43 | + condition. Un `tracing::warn!` est émis une fois par processus pour | |
| 44 | + signaler l'usage de l'API dépréciée. | |
| 45 | + | |
| 46 | +Sur le chemin canonique, le middleware **pose lui-même** l'`Arc` capturé | |
| 47 | +dans `request.extensions()` avant de passer la main. Les extracteurs | |
| 48 | +internes (`AuthUser`, `auth_required`, `admin_required`, `admin_helper`) | |
| 49 | +voient donc exactement la même instance, y compris dans une pile composée | |
| 50 | +à la main sans layer `Extension`. Dans le câblage canonique, le layer | |
| 51 | +`Extension(Arc<AuthConfig>)` d'`app.rs` réinsère ensuite le même `Arc` : | |
| 52 | +c'est un no-op volontairement conservé. | |
| 53 | + | |
| 54 | +### Précédence des overrides | |
| 55 | + | |
| 56 | +`RustwardenBuilder::build()` résout le mode d'échec ainsi : | |
| 14 | 57 | |
| 15 | -Les deux corrigent le même piège : l'ordre des layers tower configuré | |
| 16 | -par défaut dans [`app.rs`](../crates/rustwarden-core/src/app.rs) place | |
| 17 | -`AuthService` **avant** l'extension `Extension(Arc<AuthConfig>)`, donc | |
| 18 | -au moment où le middleware s'exécute l'extension n'est pas encore | |
| 19 | -disponible dans `request.extensions()`. Sans fallback, le résultat est | |
| 20 | -soit une erreur 500 silencieuse (avant RFC 0022) soit des | |
| 21 | -`claims = None` silencieux (avant RFC 0031). | |
| 58 | +``` | |
| 59 | +.auth_failure_mode(..) > AUTH_FAILURE_MODE (env) > bascule .headless() (Json401) > Redirect | |
| 60 | +``` | |
| 22 | 61 | |
| 23 | -Cette page documente le comportement final : ce qui est garanti, ce | |
| 24 | -qui ne l'est pas, et comment câbler ses propres routes. | |
| 62 | +`.login_path(..)` prime sur `LOGIN_PATH`, qui prime sur `/login`. Une valeur | |
| 63 | +d'environnement explicite n'est pas un « défaut » : elle l'emporte sur la | |
| 64 | +bascule `headless`, ce qui permet un rollback sans redéploiement de code. | |
| 25 | 65 | |
| 26 | 66 | ## Les deux branches de `AuthService` |
| 27 | 67 |
@@ -30,61 +70,44 @@ chemin demandé :
| 30 | 70 | |
| 31 | 71 | | Branche | Condition d'entrée | Mode | Réaction si JWT absent / invalide | |
| 32 | 72 | |---|---|---|---| |
| 33 | -| **Protected** | `path` commence par un préfixe de `protected_prefixes` | Authentification **obligatoire** | Redirige vers `/login?redirect=…` (303) | | |
| 73 | +| **Protected** | `path` commence par un préfixe de `protected_prefixes` | Authentification **obligatoire** | `auth_failure_mode` : `303` vers `login_path?redirect=…` ou `401 {"error":"unauthorized"}` | | |
| 34 | 74 | | **Optional auth** | `path` ne matche aucun préfixe protégé | Authentification **best-effort** | Aucune action ; la requête traverse avec `claims = None` | |
| 35 | 75 | |
| 36 | -Dans les deux branches, dès qu'un JWT est présent et valide les | |
| 37 | -claims sont injectés dans `request.extensions()` : | |
| 76 | +Dans les deux branches, dès qu'un JWT est présent, valide et non | |
| 77 | +blacklisté, les claims sont injectés dans `request.extensions()` : | |
| 38 | 78 | |
| 39 | 79 | - `Extension<Claims>` (struct fortement typée) |
| 40 | 80 | - `Extension<String>` (JSON sérialisé, utile pour les templates Askama) |
| 41 | 81 | |
| 42 | -Les handlers consomment soit `Extension<Claims>` (la route exige un | |
| 43 | -user authentifié), soit `Option<Extension<Claims>>` (le handler s'adapte | |
| 44 | -à un visiteur anonyme). | |
| 45 | - | |
| 46 | -## Le pré-requis fallback `AuthConfig::from_env()` | |
| 47 | - | |
| 48 | -Pour valider la signature d'un JWT, `AuthService` a besoin du secret | |
| 49 | -HMAC stocké dans `AuthConfig.jwt_secret`. Deux sources possibles : | |
| 50 | - | |
| 51 | -1. **`Extension(Arc<AuthConfig>)`** posée par un layer downstream. C'est | |
| 52 | - la voie « normale » que [`app.rs`](../crates/rustwarden-core/src/app.rs) | |
| 53 | - active dans son `ServiceBuilder`. | |
| 54 | -2. **`AuthConfig::from_env()`** rechargé en fallback. Cette voie | |
| 55 | - garantit que le middleware reste fonctionnel même si l'ordre des | |
| 56 | - layers ne pose pas l'extension avant lui. | |
| 57 | - | |
| 58 | -Les deux branches (protected et optional) appliquent désormais le | |
| 59 | -**même** fallback. La conséquence pratique : pourvu que la variable | |
| 60 | -d'environnement `JWT_SECRET` soit définie (validée au démarrage par | |
| 61 | -`AuthConfig::from_env()`), le middleware fonctionne indépendamment de | |
| 62 | -l'ordre exact des layers que le consommateur compose. | |
| 63 | - | |
| 64 | -### Coût du fallback | |
| 65 | - | |
| 66 | -Négligeable. Le fallback ne s'enclenche que si `Extension(Arc<AuthConfig>)` | |
| 67 | -est absente, ce qui peut arriver une fois par requête en mode dégradé. | |
| 68 | -Le parsing env est en O(n) sur quelques variables et reste sous la | |
| 69 | -microseconde sur des machines modernes. | |
| 70 | - | |
| 71 | -Si le fallback `from_env()` lui-même échoue (par exemple `JWT_SECRET` | |
| 72 | -absent — situation anormale, le serveur n'aurait pas démarré), un | |
| 73 | -`tracing::error!` est émis : | |
| 74 | - | |
| 75 | -``` | |
| 76 | -AuthService optional_auth: AuthConfig fallback failed: <message> | |
| 77 | -``` | |
| 78 | - | |
| 79 | -ou pour la branche protected : | |
| 80 | - | |
| 81 | -``` | |
| 82 | -AuthService: AuthConfig unavailable (no extension and from_env failed): <message> | |
| 82 | +`AdminService` applique la même logique, avec vérification du rôle | |
| 83 | +`admin`, sur `/admin*` et `/api/v1/admin*`. | |
| 84 | + | |
| 85 | +## Composer sa propre pile tower | |
| 86 | + | |
| 87 | +```rust | |
| 88 | +use std::sync::Arc; | |
| 89 | +use rustwarden_core::config::auth::AuthConfig; | |
| 90 | +use rustwarden_core::middleware::{admin_service::AdminService, auth_service::AuthService}; | |
| 91 | +use tower::ServiceBuilder; | |
| 92 | + | |
| 93 | +let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 94 | +let prefixes = Arc::new(vec!["/api/private".to_string()]); | |
| 95 | +let app = router.layer( | |
| 96 | + ServiceBuilder::new() | |
| 97 | + .layer_fn({ | |
| 98 | + let (db, cfg) = (db.clone(), auth_config.clone()); | |
| 99 | + move |s| AdminService::with_auth_config(s, db.clone(), cfg.clone()) | |
| 100 | + }) | |
| 101 | + .layer_fn({ | |
| 102 | + let (db, cfg, p) = (db.clone(), auth_config.clone(), prefixes.clone()); | |
| 103 | + move |s| AuthService::with_auth_config(s, db.clone(), p.clone(), cfg.clone()) | |
| 104 | + }) | |
| 105 | + .into_inner(), | |
| 106 | +); | |
| 107 | +// Pas besoin de poser Extension(Arc<AuthConfig>) soi-même : le middleware le fait. | |
| 83 | 108 | ``` |
| 84 | 109 | |
| 85 | -Côté branche protected, ce log précède un `500 Internal Server Error`. | |
| 86 | -Côté branche optional, la requête traverse sans claims (best-effort, | |
| 87 | -le handler reçoit `claims = None`). | |
| 110 | +Exemple complet et compilable : [`app/examples/custom_auth_stack.rs`](../app/examples/custom_auth_stack.rs). | |
| 88 | 111 | |
| 89 | 112 | ## Quand ai-je besoin de `register_protected_prefix()` ? |
| 90 | 113 |
@@ -93,16 +116,14 @@ Le `RustwardenBuilder` expose `register_protected_prefix(prefix)` pour
| 93 | 116 | légitimes de l'utiliser : |
| 94 | 117 | |
| 95 | 118 | 1. **Rejeter automatiquement les non-authentifiés** sur certaines |
| 96 | - routes. Sans préfixe protégé, un visiteur anonyme atteindra le | |
| 97 | - handler et il devra refuser lui-même via `Extension<Claims>` (qui | |
| 98 | - provoque un `400 Bad Request` axum) ou un check explicite. Avec | |
| 99 | - préfixe protégé, le middleware redirige vers `/login` avant même | |
| 100 | - que le handler tourne. | |
| 119 | + routes, selon `auth_failure_mode`. Sans préfixe protégé, un visiteur | |
| 120 | + anonyme atteindra le handler et il devra refuser lui-même via | |
| 121 | + `Extension<Claims>` (qui provoque un `400 Bad Request` axum) ou un | |
| 122 | + check explicite. | |
| 101 | 123 | |
| 102 | 124 | 2. **Activer les vérifications additionnelles** (must-change-password, |
| 103 | 125 | 2FA forcé global, blacklist JWT) qui ne tournent que sur la branche |
| 104 | - protected. Si votre route doit refuser un user dont | |
| 105 | - `must_change_password = true`, elle doit être en protected. | |
| 126 | + protected. | |
| 106 | 127 | |
| 107 | 128 | À l'inverse, **pas besoin** de déclarer un préfixe protégé juste pour |
| 108 | 129 | que les claims soient injectés : depuis RFC 0031, la branche optional |
@@ -110,41 +131,59 @@ suffit.
| 110 | 131 | |
| 111 | 132 | ## Troubleshooting |
| 112 | 133 | |
| 134 | +### « Mon app headless répond 303 vers /login au lieu de 401 » | |
| 135 | + | |
| 136 | +Symptôme du bug corrigé par la RFC 0042 : avant 1.2.0, la bascule | |
| 137 | +`Json401` de `.headless()` n'atteignait pas les middlewares. Depuis | |
| 138 | +1.2.0, `headless()` produit bien un `401` sauf si `AUTH_FAILURE_MODE` | |
| 139 | +ou `.auth_failure_mode(..)` disent autre chose. Si vous êtes encore sur | |
| 140 | +une version antérieure : posez `AUTH_FAILURE_MODE=json401` dans | |
| 141 | +l'environnement. | |
| 142 | + | |
| 143 | +### « Mon app headless + SSR répond 401 au lieu de rediriger vers /login » | |
| 144 | + | |
| 145 | +Conséquence attendue de la RFC 0042 pour une app `.headless()` qui | |
| 146 | +comptait sur l'ancien comportement (bug). Deux remèdes : | |
| 147 | + | |
| 148 | +- dans le code : `.auth_failure_mode(AuthFailureMode::Redirect)` sur le builder ; | |
| 149 | +- sans redéploiement : `AUTH_FAILURE_MODE=redirect` dans l'environnement. | |
| 150 | + | |
| 151 | +### « `.login_path("/auth/signin")` est ignoré » | |
| 152 | + | |
| 153 | +Avant 1.2.0 : bug RFC 0042, seule la variable `LOGIN_PATH` était lue par | |
| 154 | +les middlewares. Depuis 1.2.0 : vérifiez que vous passez par | |
| 155 | +`RustwardenApp::builder()` ou, si vous composez votre pile, que vous | |
| 156 | +utilisez `with_auth_config` et non `new` (déprécié : cherchez le warning | |
| 157 | +`deprecated constructor path` dans les logs au premier appel). | |
| 158 | + | |
| 113 | 159 | ### « Login → /dashboard redirige vers /login sans erreur » |
| 114 | 160 | |
| 115 | 161 | Symptôme historique du bug RFC 0031 : le cookie JWT est bien posé, |
| 116 | 162 | le user est authentifié, mais `/dashboard` (ou toute route hors des |
| 117 | -préfixes protégés par défaut) redirige vers `/login`. | |
| 118 | - | |
| 119 | -**Diagnostic** : | |
| 163 | +préfixes protégés) redirige vers `/login`. | |
| 120 | 164 | |
| 121 | 165 | 1. Confirmer que le cookie est transmis : |
| 122 | 166 | ```bash |
| 123 | 167 | curl -v -b /tmp/cookies.txt http://localhost:4000/dashboard 2>&1 | grep -E "HTTP|cookie" |
| 124 | 168 | ``` |
| 125 | 169 | 2. Vérifier les logs serveur pour `JWT extracted from cookie`. |
| 126 | -3. Vérifier la version de `rustwarden-core` : si elle est antérieure | |
| 127 | - au fix RFC 0031, vous **devez** soit bumper, soit déclarer | |
| 128 | - `register_protected_prefix("/dashboard")` comme contournement. | |
| 129 | - | |
| 130 | -**Après bump RFC 0031** : le fallback automatique injecte les claims | |
| 131 | -même si l'extension `AuthConfig` n'est pas encore posée par les layers. | |
| 132 | -Vous pouvez retirer les `register_protected_prefix()` ajoutés | |
| 133 | -uniquement pour contourner ce bug — **gardez** ceux qui sont là pour | |
| 134 | -les deux raisons légitimes ci-dessus. | |
| 170 | +3. Vérifier la version de `rustwarden-core` : antérieure à RFC 0031 → | |
| 171 | + bumper, ou déclarer `register_protected_prefix("/dashboard")` comme | |
| 172 | + contournement. | |
| 135 | 173 | |
| 136 | 174 | ### « Le serveur renvoie 500 sur /admin sans log clair » |
| 137 | 175 | |
| 138 | -C'est le symptôme du bug RFC 0022 (corrigé par `d4d6828`). Si vous le | |
| 139 | -constatez avec une version récente, c'est qu'`AuthConfig::from_env()` | |
| 140 | -échoue : vérifiez `JWT_SECRET` dans l'environnement. Le log | |
| 141 | -`AuthService: AuthConfig unavailable (no extension and from_env failed)` | |
| 142 | -précise l'erreur. | |
| 176 | +Sur une pile composée avec `new` (déprécié) et sans `JWT_SECRET` : | |
| 177 | +`AuthConfig::from_env()` échoue. Le log | |
| 178 | +`AdminService: AuthConfig unavailable (no extension and from_env failed)` | |
| 179 | +précise l'erreur. Passez à `with_auth_config` : la configuration est | |
| 180 | +validée une fois au démarrage et ce chemin disparaît. | |
| 143 | 181 | |
| 144 | 182 | ## Voir aussi |
| 145 | 183 | |
| 146 | -- [RFC 0022 — fallback protected](rfcs/0022-smoke-tests-regression.md) (commit `d4d6828`) | |
| 184 | +- [RFC 0042 — `AuthConfig` résolue à la construction](rfcs/0042-auth-middleware-resolved-auth-config.md) | |
| 147 | 185 | - [RFC 0031 — fallback optional](rfcs/0031-auth-service-optional-fallback.md) |
| 148 | -- Source : [`crates/rustwarden-core/src/middleware/auth_service.rs`](../crates/rustwarden-core/src/middleware/auth_service.rs) | |
| 149 | -- Tests de non-régression : [`crates/rustwarden-core/tests/middleware_authconfig_fallback.rs`](../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) | |
| 186 | +- [RFC 0022 — fallback protected](rfcs/0022-smoke-tests-regression.md) (commit `d4d6828`) | |
| 187 | +- Sources : [`auth_service.rs`](../crates/rustwarden-core/src/middleware/auth_service.rs), [`admin_service.rs`](../crates/rustwarden-core/src/middleware/admin_service.rs) | |
| 188 | +- Tests : [`tests/auth_service_resolved_config.rs`](../crates/rustwarden-core/tests/auth_service_resolved_config.rs) (RFC 0042), [`tests/middleware_authconfig_fallback.rs`](../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) (chemin legacy) | |
| 150 | 189 | - Guide consommateur fédération : [`fediverse-consumer-guide.md`](fediverse-consumer-guide.md) §4 « Wire-up » |
A
docs/rfcs/0042-auth-middleware-resolved-auth-config.md
+579
-0
@@ -0,0 +1,579 @@
| 1 | +# RFC 0042 — Auth — `AuthService` / `AdminService` reçoivent l'`AuthConfig` résolue à la construction | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine BotPlace — `docs/UPSTREAM_RUSTWARDEN.md`, § « Workarounds / besoins upstream », 2026-10-02) | |
| 4 | +- **Date** : 2026-10-02 | |
| 5 | +- **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`. | |
| 6 | +- **Apps consommatrices concernées** : BotPlace (bug reproduit par `crates/botplace-ui/tests/prefixes_proteges.rs`) ; tout projet généré par `rustwarden-cli start-project` en mode headless ; `app/examples/minimal_headless.rs` ; gitrust et rs_pasterman dès qu'ils utilisent `.login_path(..)` ou `.auth_failure_mode(..)`. | |
| 7 | +- **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). | |
| 8 | +- **Type** : correctif structurel + dépréciation (API additive, aucune suppression avant la prochaine major) + **un changement de défaut effectif** à arbitrer (D1). | |
| 9 | +- **Précédents** : [RFC 0022](0022-smoke-tests-regression.md) (fallback `from_env()` branche protected), [RFC 0031](0031-auth-service-optional-fallback.md) (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 de `new`) en la rendant additive par un second constructeur. | |
| 10 | + | |
| 11 | +## Summary | |
| 12 | + | |
| 13 | +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. | |
| 14 | + | |
| 15 | +## Motivation | |
| 16 | + | |
| 17 | +### Constat (BotPlace, 2026-10-02 — vérifié sur `main` `5df0405`) | |
| 18 | + | |
| 19 | +Dans [`app.rs`](../../crates/rustwarden-core/src/app.rs) (L788-811), le `ServiceBuilder` empile, du plus externe au plus interne (le premier `.layer()` est le plus externe) : | |
| 20 | + | |
| 21 | +``` | |
| 22 | +cors → security_headers → AdminService → AuthService → session → locale | |
| 23 | + → Extension(i18n) → Extension(hooks) → Extension(auth_config) → Extension(db) → Extension(analytics) → router | |
| 24 | +``` | |
| 25 | + | |
| 26 | +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. | |
| 27 | + | |
| 28 | +`AdminService` et `AuthService` lisent `parts.extensions.get::<Arc<AuthConfig>>()` ([`auth_service.rs`](../../crates/rustwarden-core/src/middleware/auth_service.rs) L239-258 branche protected, L470-484 branche optional ; [`admin_service.rs`](../../crates/rustwarden-core/src/middleware/admin_service.rs) L72-90). À cet instant l'extension n'est pas encore posée : ils retombent **systématiquement** sur `AuthConfig::from_env()`. | |
| 29 | + | |
| 30 | +Or `build()` (L513-526) applique les overrides du builder **sur sa copie locale** avant de la poser en `Extension` : | |
| 31 | + | |
| 32 | +```rust | |
| 33 | +let mut auth_config = AuthConfig::from_env()?; | |
| 34 | +if let Some(path) = self.login_path.take() { auth_config.login_path = path; } | |
| 35 | +if let Some(mode) = self.auth_failure_mode { auth_config.auth_failure_mode = mode; } | |
| 36 | +else if self.headless { auth_config.auth_failure_mode = AuthFailureMode::Json401; } | |
| 37 | +``` | |
| 38 | + | |
| 39 | +Cette copie n'est jamais vue par les deux middlewares. | |
| 40 | + | |
| 41 | +### Les huit sites `AuthConfig::from_env()` en chemin de requête | |
| 42 | + | |
| 43 | +| # | Site | Voit l'extension dans le câblage canonique ? | | |
| 44 | +|---|---|---| | |
| 45 | +| 1 | `auth_service.rs` L245 (branche protected) | **Non** → `from_env()` à chaque requête | | |
| 46 | +| 2 | `auth_service.rs` L475 (branche optional) | **Non** → `from_env()` à chaque requête authentifiée | | |
| 47 | +| 3 | `admin_service.rs` L78 | **Non** → `from_env()` à chaque requête `/admin*` | | |
| 48 | +| 4 | `rustwarden-ui/src/handlers/root_handler.rs` L48 (`GET /` avec cookie JWT) | **N'essaie même pas** : `from_env()` direct | | |
| 49 | +| 5 | `auth_service.rs` L71 (`auth_failure_from_extensions`) | Oui (appelé depuis les extracteurs) | | |
| 50 | +| 6 | `middleware/admin_helper.rs` L59 | Oui | | |
| 51 | +| 7 | `auth/extractors.rs` L200 (`AuthUser`) | Oui | | |
| 52 | +| 8 | `middleware/auth_required.rs` L47 | Oui | | |
| 53 | + | |
| 54 | +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). | |
| 55 | + | |
| 56 | +### Quatre conséquences observables | |
| 57 | + | |
| 58 | +1. **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 par `AuthService` (préfixes protégés) et `AdminService` (`/admin*`, `/api/v1/admin*`). Le contrat réel devient `LOGIN_PATH` / `AUTH_FAILURE_MODE` dans l'environnement — exactement le workaround que BotPlace a dû documenter dans son `.env.example`. | |
| 59 | + | |
| 60 | +2. **La promesse de `.headless()` n'est pas tenue.** La rustdoc de `headless()` (`app.rs` L370-376) et `docs/SERVICES_REFERENCE.md` (L1335) annoncent : « bascule le défaut sur `Json401` ». En pratique, un projet headless qui enregistre `register_protected_prefix("/api/v1/private")` répond `303 See Other → /login?redirect=…` vers une page qui n'existe pas, sauf si `AUTH_FAILURE_MODE=json401` est posé dans l'environnement. `/api/v1/admin/*` (couvert par `AdminService`) a le même défaut. | |
| 61 | + | |
| 62 | +3. **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` (extracteur `AuthUser`, câblé par le scaffold CLI) → `401 JSON` ; `/api/v1/private` (préfixe protégé) → `303 → /login`. | |
| 63 | + | |
| 64 | +4. **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.rs` L452-463), et sur `GET /` avec cookie pour les apps `with_ui` : seize lectures d'environnement, allocations, validation du secret, et un `tracing::info!("Auth configuration loaded…")` (`config/auth.rs` L224-229) **par requête**. Le chemin « `500` si `from_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. | |
| 65 | + | |
| 66 | +### Pourquoi les tests ne l'attrapent pas | |
| 67 | + | |
| 68 | +- [`tests/middleware_authconfig_fallback.rs`](../../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) teste `from_env()` lui-même (idempotence, roundtrip de signature), pas la propagation des overrides. | |
| 69 | +- Les tests du builder (`app.rs` L1098 `headless_sets_flag_without_ui_prefixes`) vérifient le flag, pas le comportement HTTP résultant. | |
| 70 | +- Aucun test n'instancie `AuthService` avec un `AuthConfig` explicite : 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. | |
| 71 | + | |
| 72 | +### Historique | |
| 73 | + | |
| 74 | +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. | |
| 75 | + | |
| 76 | +### Conséquences si on ne fait rien | |
| 77 | + | |
| 78 | +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. | |
| 79 | + | |
| 80 | +**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. | |
| 81 | + | |
| 82 | +## Non-goals | |
| 83 | + | |
| 84 | +- Ne pas changer la sémantique des deux branches d'`AuthService` (protected / optional), ni la liste `protected_prefixes`, ni `register_protected_prefix`. | |
| 85 | +- 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). | |
| 86 | +- Ne pas supprimer le layer `Extension(Arc<AuthConfig>)` d'`app.rs` : il reste la source des handlers ; redondance acceptée (voir OQ3). | |
| 87 | +- Ne pas toucher à `AuthConfig::from_env()` ni au parsing de `AUTH_FAILURE_MODE` / `LOGIN_PATH`. En particulier, **ne pas ajouter de champ à `AuthConfig`** : la struct (`config/auth.rs` L37-75) a tous ses champs `pub` et n'est pas `#[non_exhaustive]`, un champ supplémentaire casserait toute construction par littéral (dont le helper de test `auth_service.rs` L514-520). | |
| 88 | +- 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 est `app.rs` L809, au démarrage). | |
| 89 | +- Ne pas introduire de `tower::Layer` dédiés (voir OQ4). | |
| 90 | +- Pas de changement de schéma DB, pas de changement de format sérialisé. | |
| 91 | + | |
| 92 | +## API proposée | |
| 93 | + | |
| 94 | +### `AuthService` | |
| 95 | + | |
| 96 | +```rust | |
| 97 | +// crates/rustwarden-core/src/middleware/auth_service.rs | |
| 98 | +#[derive(Clone)] | |
| 99 | +pub struct AuthService<S> { | |
| 100 | + inner: S, | |
| 101 | + db: DatabaseConnection, | |
| 102 | + protected_prefixes: Arc<Vec<String>>, | |
| 103 | + /// Configuration resolved once by `RustwardenBuilder::build()`. | |
| 104 | + /// `None` only on the deprecated `AuthService::new` path, which keeps | |
| 105 | + /// the historical lookup (request extension, then `AuthConfig::from_env`). | |
| 106 | + auth_config: Option<Arc<AuthConfig>>, | |
| 107 | +} | |
| 108 | + | |
| 109 | +impl<S> AuthService<S> { | |
| 110 | + /// Create the middleware with the authoritative [`AuthConfig`]. | |
| 111 | + /// | |
| 112 | + /// Used by [`crate::app::RustwardenBuilder::build`] since 1.2.0. The | |
| 113 | + /// config is captured once at construction (same pattern as | |
| 114 | + /// `security_headers_middleware`), so builder overrides such as | |
| 115 | + /// [`crate::app::RustwardenBuilder::login_path`] and | |
| 116 | + /// [`crate::app::RustwardenBuilder::auth_failure_mode`] are honoured | |
| 117 | + /// regardless of where `Extension(Arc<AuthConfig>)` sits in the stack. | |
| 118 | + /// | |
| 119 | + /// The captured config takes precedence over any | |
| 120 | + /// `Extension(Arc<AuthConfig>)` found in the request. On every request | |
| 121 | + /// the middleware also inserts it into the request extensions, so | |
| 122 | + /// downstream extractors (`AuthUser`, `auth_required`, `admin_required`) | |
| 123 | + /// observe the very same instance even in hand-composed tower stacks. | |
| 124 | + /// | |
| 125 | + /// ```no_run | |
| 126 | + /// # use std::sync::Arc; | |
| 127 | + /// # use rustwarden_core::config::auth::AuthConfig; | |
| 128 | + /// # use rustwarden_core::middleware::auth_service::AuthService; | |
| 129 | + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { | |
| 130 | + /// let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 131 | + /// let prefixes = Arc::new(vec!["/api/private".to_string()]); | |
| 132 | + /// let app = router.layer(tower::layer::layer_fn(move |inner| { | |
| 133 | + /// AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone()) | |
| 134 | + /// })); | |
| 135 | + /// # let _ = app; Ok(()) } | |
| 136 | + /// ``` | |
| 137 | + pub fn with_auth_config( | |
| 138 | + inner: S, | |
| 139 | + db: DatabaseConnection, | |
| 140 | + protected_prefixes: Arc<Vec<String>>, | |
| 141 | + auth_config: Arc<AuthConfig>, | |
| 142 | + ) -> Self { | |
| 143 | + Self { inner, db, protected_prefixes, auth_config: Some(auth_config) } | |
| 144 | + } | |
| 145 | + | |
| 146 | + /// Legacy constructor: resolves `AuthConfig` per request from the | |
| 147 | + /// extensions, then from the environment. | |
| 148 | + #[deprecated( | |
| 149 | + since = "1.2.0", | |
| 150 | + note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; \ | |
| 151 | + the per-request `AuthConfig::from_env()` fallback ignores builder \ | |
| 152 | + overrides (`login_path`, `auth_failure_mode`, `headless`). \ | |
| 153 | + Removal no earlier than 1.5.0, effective in the next major." | |
| 154 | + )] | |
| 155 | + pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self { | |
| 156 | + Self { inner, db, protected_prefixes, auth_config: None } | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +/// Authoritative config first; request extension then environment only on | |
| 161 | +/// the deprecated path. A free function so it can be called from inside the | |
| 162 | +/// `'static` boxed future returned by `call` (where `self` is unavailable). | |
| 163 | +fn resolve_auth_config( | |
| 164 | + captured: Option<&Arc<AuthConfig>>, | |
| 165 | + extensions: &axum::http::Extensions, | |
| 166 | +) -> Result<Arc<AuthConfig>, String> { | |
| 167 | + if let Some(cfg) = captured { | |
| 168 | + return Ok(cfg.clone()); | |
| 169 | + } | |
| 170 | + if let Some(cfg) = extensions.get::<Arc<AuthConfig>>() { | |
| 171 | + return Ok(cfg.clone()); | |
| 172 | + } | |
| 173 | + AuthConfig::from_env().map(Arc::new) | |
| 174 | +} | |
| 175 | +``` | |
| 176 | + | |
| 177 | +Dans `call` (signature à passer en `fn call(&mut self, mut req: Request<B>)`), **avant** le `Box::pin` : | |
| 178 | + | |
| 179 | +```rust | |
| 180 | +// Seed the request so every downstream extractor sees the same config, | |
| 181 | +// whatever the consumer's layer ordering. In the canonical wiring the | |
| 182 | +// `Extension(Arc<AuthConfig>)` layer later re-inserts the same `Arc` | |
| 183 | +// (a no-op); the seeding only matters for hand-composed stacks. | |
| 184 | +if let Some(cfg) = &self.auth_config { | |
| 185 | + req.extensions_mut().insert(cfg.clone()); | |
| 186 | +} | |
| 187 | +// Cloned here because the boxed future below cannot borrow `self`. | |
| 188 | +let captured = self.auth_config.clone(); | |
| 189 | +``` | |
| 190 | + | |
| 191 | +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é. | |
| 192 | + | |
| 193 | +### `AdminService` | |
| 194 | + | |
| 195 | +```rust | |
| 196 | +// crates/rustwarden-core/src/middleware/admin_service.rs | |
| 197 | +#[derive(Clone)] | |
| 198 | +pub struct AdminService<S> { | |
| 199 | + inner: S, | |
| 200 | + db: DatabaseConnection, | |
| 201 | + auth_config: Option<Arc<AuthConfig>>, | |
| 202 | +} | |
| 203 | + | |
| 204 | +impl<S> AdminService<S> { | |
| 205 | + /// Create the middleware with the authoritative [`AuthConfig`] | |
| 206 | + /// (see `AuthService::with_auth_config` for the rationale). | |
| 207 | + /// | |
| 208 | + /// ```no_run | |
| 209 | + /// # use std::sync::Arc; | |
| 210 | + /// # use rustwarden_core::config::auth::AuthConfig; | |
| 211 | + /// # use rustwarden_core::middleware::admin_service::AdminService; | |
| 212 | + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { | |
| 213 | + /// let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 214 | + /// let app = router.layer(tower::layer::layer_fn(move |inner| { | |
| 215 | + /// AdminService::with_auth_config(inner, db.clone(), auth_config.clone()) | |
| 216 | + /// })); | |
| 217 | + /// # let _ = app; Ok(()) } | |
| 218 | + /// ``` | |
| 219 | + pub fn with_auth_config(inner: S, db: DatabaseConnection, auth_config: Arc<AuthConfig>) -> Self { | |
| 220 | + Self { inner, db, auth_config: Some(auth_config) } | |
| 221 | + } | |
| 222 | + | |
| 223 | + #[deprecated( | |
| 224 | + since = "1.2.0", | |
| 225 | + note = "use `AdminService::with_auth_config(inner, db, auth_config)`. \ | |
| 226 | + Removal no earlier than 1.5.0, effective in the next major." | |
| 227 | + )] | |
| 228 | + pub fn new(inner: S, db: DatabaseConnection) -> Self { | |
| 229 | + Self { inner, db, auth_config: None } | |
| 230 | + } | |
| 231 | +} | |
| 232 | +``` | |
| 233 | + | |
| 234 | +Même fonction libre `resolve_auth_config` (partagée via `pub(crate)` depuis `auth_service.rs`), même seeding, même `mut req`. | |
| 235 | + | |
| 236 | +### `RustwardenBuilder::build()` (`app.rs`) | |
| 237 | + | |
| 238 | +```rust | |
| 239 | +// 3. Auth config & session | |
| 240 | +let failure_mode_set_in_env = std::env::var_os("AUTH_FAILURE_MODE").is_some(); | |
| 241 | +let auth_config = self.apply_auth_overrides( | |
| 242 | + AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?, | |
| 243 | + failure_mode_set_in_env, | |
| 244 | +); | |
| 245 | +// Must stay AFTER `apply_auth_overrides`: the session layer reads | |
| 246 | +// `session_timeout_minutes` / cookie settings from the final config. | |
| 247 | +let session_layer = SessionConfig::create_memory_layer(&auth_config); | |
| 248 | +let auth_config = Arc::new(auth_config); | |
| 249 | +let cfg_for_admin = auth_config.clone(); | |
| 250 | +let cfg_for_auth = auth_config.clone(); | |
| 251 | +// ... | |
| 252 | +ServiceBuilder::new() | |
| 253 | + .layer(cors_layer) | |
| 254 | + .layer(/* security_headers_middleware, unchanged */) | |
| 255 | + .layer_fn(move |service| { | |
| 256 | + AdminService::with_auth_config(service, db_for_admin.clone(), cfg_for_admin.clone()) | |
| 257 | + }) | |
| 258 | + .layer_fn(move |service| { | |
| 259 | + AuthService::with_auth_config( | |
| 260 | + service, | |
| 261 | + db_for_auth.clone(), | |
| 262 | + prefixes_for_auth.clone(), | |
| 263 | + cfg_for_auth.clone(), | |
| 264 | + ) | |
| 265 | + }) | |
| 266 | + .layer(session_layer) | |
| 267 | + // ... | |
| 268 | + .layer(axum::Extension(auth_config)) // kept on purpose, see OQ3 | |
| 269 | +``` | |
| 270 | + | |
| 271 | +Extraction d'une fonction **pure** (aucune lecture d'environnement ; le signal env est passé en paramètre) pour la précédence des overrides : | |
| 272 | + | |
| 273 | +```rust | |
| 274 | +impl RustwardenBuilder { | |
| 275 | + /// Apply builder overrides on top of the environment-loaded config. | |
| 276 | + /// | |
| 277 | + /// Precedence (D1): explicit setter > `AUTH_FAILURE_MODE` in the | |
| 278 | + /// environment > `headless()` default flip > `Redirect`. | |
| 279 | + fn apply_auth_overrides(&self, mut cfg: AuthConfig, failure_mode_set_in_env: bool) -> AuthConfig { | |
| 280 | + if let Some(path) = &self.login_path { | |
| 281 | + cfg.login_path = path.clone(); | |
| 282 | + } | |
| 283 | + if let Some(mode) = self.auth_failure_mode { | |
| 284 | + cfg.auth_failure_mode = mode; | |
| 285 | + } else if self.headless && !failure_mode_set_in_env { | |
| 286 | + cfg.auth_failure_mode = AuthFailureMode::Json401; | |
| 287 | + } | |
| 288 | + cfg | |
| 289 | + } | |
| 290 | +} | |
| 291 | +``` | |
| 292 | + | |
| 293 | +### Correctif compagnon — `rustwarden-ui/src/handlers/root_handler.rs` (site 4) | |
| 294 | + | |
| 295 | +```rust | |
| 296 | +// Prefer the config resolved at build time; keep `from_env()` as a defence | |
| 297 | +// for stacks that do not pose the extension. | |
| 298 | +let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() { | |
| 299 | + Some(cfg) => cfg, | |
| 300 | + None => match AuthConfig::from_env() { | |
| 301 | + Ok(cfg) => Arc::new(cfg), | |
| 302 | + Err(_) => { /* unchanged: warn + redirect to login */ } | |
| 303 | + }, | |
| 304 | +}; | |
| 305 | +``` | |
| 306 | + | |
| 307 | +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`. | |
| 308 | + | |
| 309 | +### Exemple consommateur : pile tower composée à la main (hors builder) | |
| 310 | + | |
| 311 | +```rust | |
| 312 | +let auth_config = Arc::new(AuthConfig::from_env()?); | |
| 313 | +let app = router.layer( | |
| 314 | + ServiceBuilder::new() | |
| 315 | + .layer_fn({ | |
| 316 | + let (db, cfg) = (db.clone(), auth_config.clone()); | |
| 317 | + move |s| AdminService::with_auth_config(s, db.clone(), cfg.clone()) | |
| 318 | + }) | |
| 319 | + .layer_fn({ | |
| 320 | + let (db, cfg, p) = (db.clone(), auth_config.clone(), prefixes.clone()); | |
| 321 | + move |s| AuthService::with_auth_config(s, db.clone(), p.clone(), cfg.clone()) | |
| 322 | + }) | |
| 323 | + .into_inner(), | |
| 324 | +); | |
| 325 | +// No need to add Extension(Arc<AuthConfig>) yourself: the middleware seeds it. | |
| 326 | +``` | |
| 327 | + | |
| 328 | +À livrer tel quel dans `app/examples/custom_auth_stack.rs` (processus §8, item 5 : exemple obligatoire pour un pattern middleware). | |
| 329 | + | |
| 330 | +### Reproduction (avant / après) | |
| 331 | + | |
| 332 | +Diff réel sur `app/examples/minimal_headless.rs` : | |
| 333 | + | |
| 334 | +```diff | |
| 335 | + RustwardenApp::builder() | |
| 336 | + .from_env() | |
| 337 | + .headless() | |
| 338 | + .auto_migrate(true) | |
| 339 | ++ .register_protected_prefix("/api/status") | |
| 340 | + .merge_machine_routes(api_routes()) | |
| 341 | +``` | |
| 342 | + | |
| 343 | +```bash | |
| 344 | +unset AUTH_FAILURE_MODE | |
| 345 | +cargo run -p rustwarden-app --example minimal_headless & | |
| 346 | +curl -si http://localhost:3000/api/status | head -2 | |
| 347 | +# Avant : HTTP/1.1 303 See Other ← attendu 401 en headless | |
| 348 | +# location: /login?redirect=%2Fapi%2Fstatus | |
| 349 | +# Après : HTTP/1.1 401 Unauthorized | |
| 350 | +# {"error":"unauthorized"} | |
| 351 | +``` | |
| 352 | + | |
| 353 | +## Analyse de compatibilité | |
| 354 | + | |
| 355 | +- [x] **API existante étendue** (`with_auth_config` sur les deux middlewares, `apply_auth_overrides` privé) → non-breaking, minor. | |
| 356 | +- [x] **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ée `Deprecated` à reporter dans **chaque** minor intermédiaire. Appelants de `new` dans le workspace au 2026-10-02 : `app.rs` L797 et L799 uniquement ; les templates `rustwarden-cli` n'appellent pas les constructeurs ; aucun appel dans `rustwarden-ui`, `rustwarden-fediverse*`, `app/`. | |
| 357 | +- [ ] **API existante modifiée ou supprimée** → non. Mais voir la ligne suivante. | |
| 358 | +- [x] **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(..)` **ni** `AUTH_FAILURE_MODE` posé → leurs préfixes protégés et `/api/v1/admin*` passent de `303 → /login` (bug) à `401 JSON` (défaut documenté depuis `05aeaca`, `SERVICES_REFERENCE.md` L1335). Pour les apps `with_ui(..)` : rien ne change. | |
| 359 | + - **Recensement des consommateurs connus** : BotPlace est `.headless()` avec UI SSR propre, mais appelle `.auth_failure_mode(AuthFailureMode::Redirect)` sur le builder **et** pose `AUTH_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 apps `with_ui` → non affectées. Les projets scaffoldés par `rustwarden-cli` en headless n'enregistrent aucun préfixe protégé par défaut et protègent `whoami` par l'extracteur `AuthUser` (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 le `303` sans setter ni env — aucune connue ; remédiation sans redéploiement de code : `AUTH_FAILURE_MODE=redirect`. | |
| 360 | + - **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. | |
| 361 | + - **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 patch `1.2.1` de 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). | |
| 362 | +- [x] **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 un `303`. | |
| 363 | +- [x] **Extracteurs internes** (sites 5 à 8) : inchangés ; ils voient la même instance qu'avant. | |
| 364 | +- [ ] Changement de schéma DB → non. | |
| 365 | +- [ ] Changement de format sérialisé → non. | |
| 366 | +- [x] **Logs** : disparition du `Auth configuration loaded` par requête sur les sites 1 à 4 (format des logs = non-stable, §6). | |
| 367 | +- [x] **Versions** : les sept manifestes `Cargo.toml` du workspace sont à `1.0.0` alors que le CHANGELOG a publié `1.0.1` et `1.1.0` (seuls tags git : `V0.1.0`, `v01`). La numérotation de cette RFC (`1.2.0`, hotfix `1.1.1`) suit le CHANGELOG ; à réaligner au moment du train. | |
| 368 | + | |
| 369 | +## Alternatives considérées | |
| 370 | + | |
| 371 | +### Alternative A (recommandée) : injection par constructeur + seeding + dépréciation + bascule headless effective | |
| 372 | + | |
| 373 | +Décrite ci-dessus, avec la précédence setter > env > headless. | |
| 374 | + | |
| 375 | +**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. | |
| 376 | + | |
| 377 | +**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. | |
| 378 | + | |
| 379 | +### Alternative B : remonter `Extension(auth_config)` en tête de pile | |
| 380 | + | |
| 381 | +Poser `.layer(axum::Extension(auth_config.clone()))` en `Router::layer` après le `ServiceBuilder`, comme `revocation_checker` (`app.rs` L815-820). | |
| 382 | + | |
| 383 | +**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`. | |
| 384 | + | |
| 385 | +**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). | |
| 386 | + | |
| 387 | +### Alternative C : `tower::Layer` dédiés (`AuthLayer`, `AdminLayer`) | |
| 388 | + | |
| 389 | +**Pros** : plus idiomatique ; les consommateurs réutilisent le layer sans closure. | |
| 390 | + | |
| 391 | +**Cons** : surface d'API supplémentaire à stabiliser ; ne corrige rien de plus que A ; peut être ajouté par-dessus A ultérieurement (OQ4). | |
| 392 | + | |
| 393 | +### Alternative D : ne rien faire (documenter que le contrat réel est l'environnement) | |
| 394 | + | |
| 395 | +**Pros** : zéro coût immédiat. | |
| 396 | + | |
| 397 | +**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 }`. | |
| 398 | + | |
| 399 | +### Alternative E : injection en 1.2.0, bascule headless différée derrière un opt-in | |
| 400 | + | |
| 401 | +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). | |
| 402 | + | |
| 403 | +**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`. | |
| 404 | + | |
| 405 | +**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. | |
| 406 | + | |
| 407 | +**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. | |
| 408 | + | |
| 409 | +## Testing plan | |
| 410 | + | |
| 411 | +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`. | |
| 412 | + | |
| 413 | +### Mock de la blacklist | |
| 414 | + | |
| 415 | +`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`) : | |
| 416 | + | |
| 417 | +```rust | |
| 418 | +use sea_orm::{DatabaseBackend, MockDatabase, Value}; | |
| 419 | +use std::collections::BTreeMap; | |
| 420 | + | |
| 421 | +fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> { | |
| 422 | + BTreeMap::from([("num_items", Value::BigInt(Some(n)))]) | |
| 423 | +} | |
| 424 | +let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 425 | + .append_query_results([vec![blacklist_count(0)]]) | |
| 426 | + .into_connection(); | |
| 427 | +``` | |
| 428 | + | |
| 429 | +### Unitaires — `auth_service.rs` | |
| 430 | + | |
| 431 | +1. **Happy** : préfixe protégé, pas de JWT, `Json401` injecté → `401` + corps `{"error":"unauthorized"}` (aucune requête DB émise). | |
| 432 | +2. **Happy** : `Redirect` + `login_path = "/auth/signin"` injectés → `303` + `Location: /auth/signin?redirect=%2Fprivate`. | |
| 433 | +3. **Seeding** : route hors préfixe, handler lisant `Extension<Arc<AuthConfig>>`, **aucun** layer `Extension` dans la pile de test → le `login_path` renvoyé est celui injecté. | |
| 434 | +4. **Erreur — signature invalide** : JWT signé avec un autre secret, blacklist mockée à `0` (le flux atteint `validate_token`), `Json401` → `401`. Assertion complémentaire : le journal du mock (`into_transaction_log()`) contient exactement une requête `COUNT`. | |
| 435 | +5. **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é. | |
| 436 | +6. **Legacy / preuve d'indépendance à l'environnement** — dans `crates/rustwarden-core/tests/auth_service_resolved_config.rs` (binaire isolé, pattern `ENV_LOCK` + `remove_var("JWT_SECRET")` de `tests/middleware_authconfig_fallback.rs` L19-46) : avec `JWT_SECRET` retiré, `AuthService::with_auth_config` répond `401` sur un préfixe protégé là où `AuthService::new` (`#[allow(deprecated)]`) répond `500` ; avec `Extension` posée en amont, `new` l'utilise (comportement historique conservé). | |
| 437 | + | |
| 438 | +### Unitaires — `admin_service.rs` | |
| 439 | + | |
| 440 | +7. `/admin` et `/api/v1/admin/x` sans JWT, `Json401` → `401` ; `Redirect` + `login_path` custom → `303` vers ce chemin. | |
| 441 | + | |
| 442 | +### Unitaires — `app.rs` | |
| 443 | + | |
| 444 | +8. `apply_auth_overrides` (fonction pure, parallélisable) : `headless()` → `Json401` ; setter `Redirect` prime sur `headless()` ; `failure_mode_set_in_env = true` prime sur `headless()` ; `login_path` propagé ; non-headless sans setter → inchangé. | |
| 445 | + | |
| 446 | +### Unitaires — `rustwarden-ui` | |
| 447 | + | |
| 448 | +9. `root_redirect` avec `Extension(Arc<AuthConfig>)` posée et cookie JWT valide → redirige vers `/home` sans appel à `from_env()` (vérifiable par un `login_path` non standard dans la config injectée, impossible à obtenir depuis l'environnement du test). | |
| 449 | + | |
| 450 | +### Intégration | |
| 451 | + | |
| 452 | +10. `tests/middleware_authconfig_fallback.rs` conservé tel quel (il couvre `from_env()` et le chemin déprécié). | |
| 453 | +11. 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 lit `Extension<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 **sans** `AUTH_FAILURE_MODE`. | |
| 454 | + | |
| 455 | +### Non-régression | |
| 456 | + | |
| 457 | +12. `cargo build --workspace --examples` sans warning (aucun appel à `new` ne subsiste hors tests). | |
| 458 | +13. `cargo clippy --workspace` zéro warning ; `cargo doc` sans warning (vérifier en particulier les liens intra-doc vers les items dépréciés). | |
| 459 | +14. Projet généré par `rustwarden-cli start-project` en headless : compile et répond `401` sur `/api/v1/admin/users` sans token. | |
| 460 | + | |
| 461 | +### Doctests | |
| 462 | + | |
| 463 | +15. Les exemples `no_run` de `AuthService::with_auth_config` et `AdminService::with_auth_config` compilent. | |
| 464 | + | |
| 465 | +### Benchs (processus §7 : obligatoire, hot path touché) | |
| 466 | + | |
| 467 | +16. Micro-mesure `AuthConfig::from_env()` seule (`std::time::Instant`, 10 000 itérations) et `oneshot` sur préfixe protégé sans JWT, pile legacy vs pile `with_auth_config`, même machine. Livré : test `#[ignore]` `bench_from_env_versus_captured_config` dans `tests/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 ; pile `with_auth_config` 37,7 µs/req (−6 %). Aucune dépendance de bench ajoutée. | |
| 468 | + | |
| 469 | +### Observabilité | |
| 470 | + | |
| 471 | +17. `Auth configuration loaded` n'apparaît qu'une fois au démarrage sur une app `with_ui` **et** sur une app headless (sites 1 à 4 couverts). | |
| 472 | + | |
| 473 | +## Migration guide | |
| 474 | + | |
| 475 | +Section à copier dans `CHANGELOG.md` au merge (`[1.2.0]`). | |
| 476 | + | |
| 477 | +```markdown | |
| 478 | +### Fixed | |
| 479 | + | |
| 480 | +- **RFC 0042.** `AuthService` et `AdminService` honorent désormais | |
| 481 | + `RustwardenBuilder::login_path`, `::auth_failure_mode` et la bascule | |
| 482 | + `Json401` de `::headless()`. Les deux middlewares retombaient | |
| 483 | + systématiquement sur `AuthConfig::from_env()` (ordre des layers) et ne | |
| 484 | + lisaient que `LOGIN_PATH` / `AUTH_FAILURE_MODE`. Préfixes protégés, | |
| 485 | + `/admin*` et extracteurs `AuthUser` / `auth_required` partagent enfin la | |
| 486 | + même configuration. `rustwarden-ui::root_redirect` lit aussi la | |
| 487 | + configuration injectée avant de retomber sur l'environnement. | |
| 488 | + | |
| 489 | +### Changed | |
| 490 | + | |
| 491 | +- Apps `.headless()` sans `.auth_failure_mode(..)` ni `AUTH_FAILURE_MODE` : | |
| 492 | + les préfixes enregistrés via `register_protected_prefix` et `/api/v1/admin*` | |
| 493 | + répondent `401 {"error":"unauthorized"}` au lieu de `303 → /login` | |
| 494 | + (comportement documenté, jusqu'ici non appliqué). Si votre app headless | |
| 495 | + sert une UI SSR, appelez `.auth_failure_mode(AuthFailureMode::Redirect)` | |
| 496 | + ou posez `AUTH_FAILURE_MODE=redirect` (désormais prioritaire sur la | |
| 497 | + bascule `headless`). | |
| 498 | +- `AuthConfig::from_env()` n'est plus exécutée à chaque requête par les | |
| 499 | + middlewares de préfixe ni par `root_redirect` ; dans le câblage canonique, | |
| 500 | + le log `Auth configuration loaded` n'apparaît plus qu'au démarrage. | |
| 501 | + | |
| 502 | +### Deprecated | |
| 503 | + | |
| 504 | +- `AuthService::new(inner, db, prefixes)` → `AuthService::with_auth_config(inner, db, prefixes, auth_config)`. | |
| 505 | +- `AdminService::new(inner, db)` → `AdminService::with_auth_config(inner, db, auth_config)`. | |
| 506 | + Suppression au plus tôt en 1.5.0, effective dans la prochaine major. | |
| 507 | +``` | |
| 508 | + | |
| 509 | +### Migration steps | |
| 510 | + | |
| 511 | +1. 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 par `RustwardenApp::builder()`. | |
| 512 | +2. Si vous composez vous-même la pile tower : remplacer `new` par `with_auth_config` et passer l'`Arc<AuthConfig>` que vous posiez déjà en `Extension`. | |
| 513 | +3. Apps headless : vérifier que le `401` sur préfixes protégés et `/api/v1/admin*` est bien le comportement voulu ; sinon `.auth_failure_mode(Redirect)` ou `AUTH_FAILURE_MODE=redirect`. | |
| 514 | +4. Les `AUTH_FAILURE_MODE` / `LOGIN_PATH` posés uniquement pour contourner le bug peuvent être retirés — ou conservés : ils restent lus par `from_env()` au démarrage. | |
| 515 | +5. Côté consommateur, fermer l'entrée « RFC upstream à ouvrir » de `docs/UPSTREAM_RUSTWARDEN.md` en pointant vers cette RFC. | |
| 516 | + | |
| 517 | +### Rollback (processus §10) | |
| 518 | + | |
| 519 | +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. | |
| 520 | + | |
| 521 | +### Documentation à mettre à jour dans le même PR | |
| 522 | + | |
| 523 | +- [`docs/auth-middleware-layering.md`](../auth-middleware-layering.md) : le fallback `from_env()` devient le chemin **legacy** ; décrire le seeding, `with_auth_config` et la précédence setter > env > headless. | |
| 524 | +- [`docs/SERVICES_REFERENCE.md`](../SERVICES_REFERENCE.md) L1333-1335 : précédence réelle des overrides. | |
| 525 | +- `.env.sample` (racine) et `template/.env.sample` : documenter `AUTH_FAILURE_MODE` et `LOGIN_PATH`, aujourd'hui absents (seul `JWT_SECRET` y figure). | |
| 526 | +- Rustdoc de `RustwardenBuilder::headless`, `login_path`, `auth_failure_mode` : énoncer la précédence et les points d'application couverts. | |
| 527 | +- `app/examples/custom_auth_stack.rs` : la pile composée à la main ci-dessus. | |
| 528 | + | |
| 529 | +## Open questions | |
| 530 | + | |
| 531 | +- [x] **OQ1 — Précédence environnement vs bascule `headless`** : tranchée (setter > env > headless > `Redirect`), implémentée par le paramètre `failure_mode_set_in_env` de `apply_auth_overrides` (fonction pure, six tests unitaires). | |
| 532 | +- [x] **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. | |
| 533 | +- [x] **OQ2 — Hotfix B en `1.1.1`** : sans objet, A livrée directement. | |
| 534 | +- [x] **OQ3 — Layer `Extension(Arc<AuthConfig>)` d'`app.rs`** : conservé (redondance sans coût) ; retrait avec les constructeurs dépréciés. | |
| 535 | +- [ ] **OQ4 — `tower::Layer` dédiés** (`AuthLayer`, `AdminLayer`) : RFC séparée si un consommateur en exprime le besoin. | |
| 536 | +- [x] **OQ5 — Signal sur le chemin déprécié** : implémenté — `tracing::warn!` une fois par processus (`std::sync::Once`) dans `resolve_auth_config` quand le chemin `from_env()` est pris. | |
| 537 | + | |
| 538 | +## Écarts d'implémentation par rapport à l'API proposée | |
| 539 | + | |
| 540 | +- `build()` résout l'`AuthConfig` **avant** la connexion à la base (étape 0) et non à l'étape 3 : la fonction `apply_auth_overrides(&self, ..)` emprunte le builder alors que `self.db` est déplacé à l'étape 1. Aucun effet fonctionnel ; `SessionConfig::create_memory_layer` reste appelée après les overrides. `build(mut self)` devient `build(self)`. | |
| 541 | +- `rustwarden-ui::root_redirect` redirige en plus vers `auth_config.login_path` au lieu de `/login` codé en dur (cohérent avec l'intention de la RFC : `.login_path()` honoré partout). Trois tests unitaires dans `rustwarden-ui`. | |
| 542 | +- 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 BotPlace `prefixes_proteges.rs` à relancer sans `AUTH_FAILURE_MODE`. | |
| 543 | +- **Signal environnement durci (revue finale)** : `failure_mode_set_in_env` n'est vrai que pour une valeur `AUTH_FAILURE_MODE` **valide** (`redirect` / `json401`). `from_env()` retombe silencieusement sur `Redirect` quand la valeur est vide ou mal orthographiée ; sans ce durcissement, une faute de frappe dans l'environnement aurait réintroduit le `303` sur une app headless. Fonction pure `RustwardenBuilder::failure_mode_overridden_by_env(raw)` testée (`None`, `""`, `"json-401"`, `"json401"`, `"Redirect"`). | |
| 544 | +- Le `tracing::warn!` du chemin déprécié est émis **après** un `from_env()` réussi, pour ne jamais précéder un `from_env failed`. | |
| 545 | +- `build()` échoue désormais sur un `JWT_SECRET` invalide **avant** la connexion DB, les migrations et l'admin par défaut (fail-fast ; consigné au CHANGELOG). | |
| 546 | +- Les deux doctests `no_run` compilent ; le test d'indépendance à l'environnement vit dans `crates/rustwarden-core/tests/auth_service_resolved_config.rs` avec quatre scénarios (canonique sans env → `401` / `303` custom ; legacy sans env → `500` ; legacy + `Extension` externe → honorée ; legacy + env → `from_env()` et overrides ignorés) plus le bench `#[ignore]`. | |
| 547 | + | |
| 548 | +## Suites à donner (hors périmètre, relevées par la revue finale) | |
| 549 | + | |
| 550 | +- **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 que `AuthService` est fail-closed (`Ok(true) | Err(_) => reject`). Préexistant, chemin le plus privilégié : ticket dédié. | |
| 551 | +- `auth_failure_from_extensions` (site 5) garde sa chaîne extension → `from_env()` sans le `warn!` `Once` : la promesse d'observabilité OQ5 ne couvre que les deux middlewares. | |
| 552 | +- Cinq copies du littéral `AuthConfig` à quinze champs dans les tests (dont deux hors `rustwarden-core`). Proposition : `#[doc(hidden)] pub fn for_tests()` derrière une feature `test-util`, à faire avant tout ajout de champ à `AuthConfig`. | |
| 553 | +- Le bench tourne sans souscripteur `tracing` : les 2,37 µs par appel sous-estiment le coût réel du `info!` par requête en production. | |
| 554 | + | |
| 555 | +## Tracking issue | |
| 556 | + | |
| 557 | +À 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. | |
| 558 | + | |
| 559 | +--- | |
| 560 | + | |
| 561 | +## Score d'évaluation (auto-évaluation proposée, à confirmer par les reviewers) | |
| 562 | + | |
| 563 | +| Critère | Note (1-5) | Commentaire | | |
| 564 | +|---|---|---| | |
| 565 | +| 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 | | |
| 566 | +| 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 | | |
| 567 | +| 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 | | |
| 568 | +| 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 | | |
| 569 | +| Complexité d'implémentation | 4 | ~90 lignes sur quatre fichiers + tests ; pattern déjà en place (`security_headers_middleware`) | | |
| 570 | +| Charge de maintenance | 5 | Zéro dépendance ; supprime du code (fallbacks) à terme | | |
| 571 | +| Documentation | 4 | Rustdoc, deux doctests, CHANGELOG, exemple, quatre documents à réviser, `.env.sample` à compléter | | |
| 572 | +| **Total** | **29/35** | ≥ 25 (31/35 avec l'Alternative E) | | |
| 573 | + | |
| 574 | +--- | |
| 575 | + | |
| 576 | +**Historique** : | |
| 577 | +- 2026-10-02 : Draft. Origine BotPlace (`docs/UPSTREAM_RUSTWARDEN.md`). Câblage vérifié sur `main` `5df0405` : `app.rs` L788-831 et L513-526, `auth_service.rs` L239-258 / L452-484, `admin_service.rs` L72-90, `admin_helper.rs` L59, `extractors.rs` L194-200, `auth_required.rs` L43-47, `config/auth.rs` L212-229, `rustwarden-ui/handlers/root_handler.rs` L48. | |
| 578 | +- 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. | |
| 579 | +- 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_overrides` pure avec signal env en paramètre, site 4 (`root_redirect`) ajouté au périmètre, recensement des huit sites, mocks `count()` 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.sample` au lieu de `.env.example`, désalignement des manifestes signalé. |
M
docs/rfcs/README.md
+3
-0
@@ -98,6 +98,7 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
| 98 | 98 | | [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Implemented** (phase 2, sans `unsafe-eval`) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 | |
| 99 | 99 | | [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Implemented** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 | |
| 100 | 100 | | [0041](0041-pending-email-changes.md) | Auth — changement d'e-mail en deux temps (`pending_email_changes`) — **remplace la [0038](0038-email-change-revalidation.md)** | **Implemented** | EBO (origine audit Gitrust / suite de la RFC 0038) | 2026-09-19 | |
| 101 | +| [0042](0042-auth-middleware-resolved-auth-config.md) | Auth — `AuthService` / `AdminService` reçoivent l'`AuthConfig` résolue (overrides `.login_path()` / `.auth_failure_mode()` / `.headless()` ignorés par les middlewares de préfixe) | **Implemented** (1.2.0, 2026-10-02 ; D1 = Alternative A, rupture de défaut headless assumée) | EBO (origine BotPlace) | 2026-10-02 | | |
| 101 | 102 | |
| 102 | 103 | À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant. |
| 103 | 104 |
@@ -105,6 +106,8 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
| 105 | 106 | |
| 106 | 107 | > **Note sur les RFCs 0029-0030** : ces deux RFCs trouvent leur origine côté gitrust (premier consommateur), tracking dans [`gitrust/`](gitrust/), évaluation détaillée dans [`docs/plans/2026-05-15-rfcs-gitrust-evaluation.md`](../plans/2026-05-15-rfcs-gitrust-evaluation.md). Une 3ᵉ RFC initialement proposée (`gitrust-0003 RemoteTeamMembershipService`) a été **rejetée** car la capacité est déjà livrée par les RFCs 0008/0009. |
| 107 | 108 | |
| 109 | +> **Note sur la remontée BotPlace du 2026-10-02** (`docs/UPSTREAM_RUSTWARDEN.md`, § « Workarounds / besoins upstream ») : deux besoins remontés. Le premier (CSP par défaut sans `'unsafe-eval'` vs build Alpine standard) ne donne **pas** lieu à RFC : le chemin est déjà fourni par la [RFC 0039](0039-security-headers-config.md) (`DEFAULT_CONTENT_SECURITY_POLICY` + `RustwardenBuilder::security_headers`), la sortie attendue est la migration consommateur vers `@alpinejs/csp`. Le second (overrides du builder non honorés par `AuthService` / `AdminService`) est un défaut générique du framework → [RFC 0042](0042-auth-middleware-resolved-auth-config.md). | |
| 110 | + | |
| 108 | 111 | ## RFCs liées au plan fediverse |
| 109 | 112 | |
| 110 | 113 | Le plan d'intégration de la fédération d'identité ([`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md) §17) prévoit 11 RFCs : |
M
tools/rustwarden-cli/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-cli" |
| 3 | -version = "1.0.0" | |
| 3 | +version = "1.2.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "CLI wizard to scaffold new Rustwarden applications" |
GitRust