Skip to main content

koprogo_api/infrastructure/web/middleware/
mod.rs

1// Story 1.3 — middleware scope_guard (ListScope + AcpNotInScope enforcement).
2pub mod scope_guard;
3pub use scope_guard::{AcpScope, ScopeGuard, ScopeGuardError};
4
5// Story 5.5 — middleware community_access_guard (comptable exclu de
6// /community/* sauf cumul owner, cf. ADR 0052 / INV-6).
7pub mod community_access_guard;
8pub use community_access_guard::CommunityAccessGuard;
9
10use crate::infrastructure::web::app_state::AppState;
11use actix_web::{
12    body::MessageBody,
13    dev::{forward_ready, Payload, Service, ServiceRequest, ServiceResponse, Transform},
14    error::ErrorUnauthorized,
15    http::StatusCode,
16    web, Error, FromRequest, HttpRequest, HttpResponse,
17};
18use std::collections::HashMap;
19use std::future::{ready, Future, Ready};
20use std::pin::Pin;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23use uuid::Uuid;
24
25/// Authenticated user claims extracted from JWT token
26///
27/// This struct automatically extracts and validates JWT tokens from the Authorization header.
28/// Use it as a parameter in your handler functions to require authentication:
29///
30/// ```rust,ignore
31/// use actix_web::Responder;
32/// use koprogo_api::infrastructure::web::middleware::AuthenticatedUser;
33///
34/// async fn protected_handler(claims: AuthenticatedUser) -> impl Responder {
35///     // claims.user_id and claims.organization_id are now available
36/// }
37/// ```
38#[derive(Debug, Clone)]
39pub struct AuthenticatedUser {
40    pub user_id: Uuid,
41    pub email: String,
42    pub role: String,
43    pub role_id: Option<Uuid>,
44    pub organization_id: Option<Uuid>,
45}
46
47impl AuthenticatedUser {
48    /// Get the organization_id or return an error if not present
49    pub fn require_organization(&self) -> Result<Uuid, Error> {
50        self.organization_id
51            .ok_or_else(|| ErrorUnauthorized("User does not belong to an organization"))
52    }
53
54    /// Check if user is superadmin (can access all organizations)
55    pub fn is_superadmin(&self) -> bool {
56        self.role == "superadmin"
57    }
58
59    /// Get effective organization_id for filtering:
60    /// - SuperAdmin: None (sees everything)
61    /// - Others: Some(org_id)
62    pub fn effective_org_filter(&self) -> Option<Uuid> {
63        if self.is_superadmin() {
64            None
65        } else {
66            self.organization_id
67        }
68    }
69
70    /// Verify that a resource's organization matches the user's organization.
71    /// SuperAdmin bypasses this check.
72    /// Returns Ok(()) if access is allowed, Err(message) if denied.
73    pub fn verify_org_access(&self, resource_org_id: Uuid) -> Result<(), String> {
74        if self.is_superadmin() {
75            return Ok(());
76        }
77        match self.organization_id {
78            Some(user_org_id) if user_org_id == resource_org_id => Ok(()),
79            Some(_) => Err("Access denied: resource belongs to another organization".to_string()),
80            None => Err("User does not belong to an organization".to_string()),
81        }
82    }
83}
84
85impl FromRequest for AuthenticatedUser {
86    type Error = Error;
87    type Future = Ready<Result<Self, Self::Error>>;
88
89    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
90        // Get AppState from request
91        let app_state = match req.app_data::<web::Data<AppState>>() {
92            Some(state) => state,
93            None => return ready(Err(ErrorUnauthorized("Internal server error"))),
94        };
95
96        // Extract Authorization header
97        let auth_header = match req.headers().get("Authorization") {
98            Some(header) => match header.to_str() {
99                Ok(s) => s,
100                Err(_) => return ready(Err(ErrorUnauthorized("Invalid authorization header"))),
101            },
102            None => return ready(Err(ErrorUnauthorized("Missing authorization header"))),
103        };
104
105        // Extract token from "Bearer <token>"
106        let token = auth_header.trim_start_matches("Bearer ").trim();
107
108        // Verify token and extract claims
109        match app_state.auth_use_cases.verify_token(token) {
110            Ok(claims) => {
111                // Parse user_id from claims.sub
112                match Uuid::parse_str(&claims.sub) {
113                    Ok(user_id) => ready(Ok(AuthenticatedUser {
114                        user_id,
115                        email: claims.email,
116                        role: claims.role,
117                        role_id: claims.role_id,
118                        organization_id: claims.organization_id,
119                    })),
120                    Err(_) => ready(Err(ErrorUnauthorized("Invalid user ID in token"))),
121                }
122            }
123            Err(e) => ready(Err(ErrorUnauthorized(e))),
124        }
125    }
126}
127
128/// Organization ID extracted from authenticated user's JWT token
129///
130/// This extractor requires that the user belongs to an organization.
131/// Use it when you need to enforce organization-scoped operations:
132///
133/// ```rust,ignore
134/// use actix_web::{Responder, web};
135/// use koprogo_api::application::dto::CreateBuildingDto;
136/// use koprogo_api::infrastructure::web::middleware::OrganizationId;
137///
138/// async fn create_building(
139///     organization: OrganizationId,
140///     dto: web::Json<CreateBuildingDto>
141/// ) -> impl Responder {
142///     // organization.0 contains the Uuid
143/// }
144/// ```
145#[derive(Debug, Clone, Copy)]
146pub struct OrganizationId(pub Uuid);
147
148impl FromRequest for OrganizationId {
149    type Error = Error;
150    type Future = Ready<Result<Self, Self::Error>>;
151
152    fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
153        // First extract AuthenticatedUser
154        let user_future = AuthenticatedUser::from_request(req, payload);
155
156        // Get the result
157        match user_future.into_inner() {
158            Ok(user) => match user.organization_id {
159                Some(org_id) => ready(Ok(OrganizationId(org_id))),
160                None => ready(Err(ErrorUnauthorized(
161                    "User does not belong to an organization",
162                ))),
163            },
164            Err(e) => ready(Err(e)),
165        }
166    }
167}
168
169// ========================================
170// GDPR Rate Limiting Middleware
171// ========================================
172
173/// Configuration for GDPR rate limiting
174#[derive(Clone, Debug)]
175pub struct GdprRateLimitConfig {
176    /// Maximum number of requests allowed per window
177    pub max_requests: usize,
178    /// Duration of the rate limit window
179    pub window_duration: Duration,
180}
181
182impl Default for GdprRateLimitConfig {
183    fn default() -> Self {
184        Self {
185            max_requests: 10,
186            window_duration: Duration::from_secs(3600), // 1 hour
187        }
188    }
189}
190
191/// Rate limit state tracking
192#[derive(Clone)]
193pub struct GdprRateLimitState {
194    state: Arc<Mutex<HashMap<String, (usize, Instant)>>>,
195    config: GdprRateLimitConfig,
196}
197
198impl GdprRateLimitState {
199    pub fn new(config: GdprRateLimitConfig) -> Self {
200        Self {
201            state: Arc::new(Mutex::new(HashMap::new())),
202            config,
203        }
204    }
205
206    /// Check if user has exceeded rate limit
207    pub fn check_rate_limit(&self, user_id: &str) -> Result<(), String> {
208        let mut state = self.state.lock().unwrap();
209        let now = Instant::now();
210        let entry = state.entry(user_id.to_string()).or_insert((0, now));
211        let (count, window_start) = entry;
212
213        // Reset window if expired
214        if now.duration_since(*window_start) > self.config.window_duration {
215            *count = 0;
216            *window_start = now;
217        }
218
219        // Check limit
220        if *count >= self.config.max_requests {
221            let reset_in = self
222                .config
223                .window_duration
224                .saturating_sub(now.duration_since(*window_start));
225            return Err(format!(
226                "Rate limit exceeded. Try again in {} seconds.",
227                reset_in.as_secs()
228            ));
229        }
230
231        *count += 1;
232        Ok(())
233    }
234}
235
236/// GDPR-specific rate limiting middleware
237///
238/// Only applies rate limits to GDPR-related endpoints:
239/// - `/api/v1/gdpr/*`
240/// - `/api/v1/admin/gdpr/*`
241#[derive(Clone)]
242pub struct GdprRateLimit {
243    state: GdprRateLimitState,
244}
245
246impl GdprRateLimit {
247    pub fn new(config: GdprRateLimitConfig) -> Self {
248        Self {
249            state: GdprRateLimitState::new(config),
250        }
251    }
252}
253
254impl<S, B> Transform<S, ServiceRequest> for GdprRateLimit
255where
256    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
257    S::Future: 'static,
258    B: MessageBody + 'static,
259{
260    type Response = ServiceResponse<actix_web::body::EitherBody<B>>;
261    type Error = Error;
262    type InitError = ();
263    type Transform = GdprRateLimitMiddleware<S>;
264    type Future = Ready<Result<Self::Transform, Self::InitError>>;
265
266    fn new_transform(&self, service: S) -> Self::Future {
267        ready(Ok(GdprRateLimitMiddleware {
268            service: Arc::new(service),
269            state: self.state.clone(),
270        }))
271    }
272}
273
274pub struct GdprRateLimitMiddleware<S> {
275    service: Arc<S>,
276    state: GdprRateLimitState,
277}
278
279impl<S, B> Service<ServiceRequest> for GdprRateLimitMiddleware<S>
280where
281    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
282    S::Future: 'static,
283    B: MessageBody + 'static,
284{
285    type Response = ServiceResponse<actix_web::body::EitherBody<B>>;
286    type Error = Error;
287    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
288
289    forward_ready!(service);
290
291    fn call(&self, req: ServiceRequest) -> Self::Future {
292        let path = req.path().to_string();
293
294        // Only apply rate limiting to GDPR endpoints
295        let is_gdpr_endpoint =
296            path.starts_with("/api/v1/gdpr") || path.starts_with("/api/v1/admin/gdpr");
297
298        if !is_gdpr_endpoint {
299            let fut = self.service.call(req);
300            return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
301        }
302
303        // Extract user_id from AuthenticatedUser
304        let user_id = match req.app_data::<web::Data<AppState>>() {
305            Some(app_state) => {
306                // Extract Authorization header
307                let auth_header = match req.headers().get("Authorization") {
308                    Some(header) => match header.to_str() {
309                        Ok(s) => s.to_string(),
310                        Err(_) => {
311                            // Let the handler deal with invalid auth
312                            let fut = self.service.call(req);
313                            return Box::pin(async move {
314                                fut.await.map(|res| res.map_into_left_body())
315                            });
316                        }
317                    },
318                    None => {
319                        // Let the handler deal with missing auth
320                        let fut = self.service.call(req);
321                        return Box::pin(
322                            async move { fut.await.map(|res| res.map_into_left_body()) },
323                        );
324                    }
325                };
326
327                let token = auth_header.trim_start_matches("Bearer ").trim();
328
329                match app_state.auth_use_cases.verify_token(token) {
330                    Ok(claims) => claims.sub,
331                    Err(_) => {
332                        // Let the handler deal with invalid token
333                        let fut = self.service.call(req);
334                        return Box::pin(
335                            async move { fut.await.map(|res| res.map_into_left_body()) },
336                        );
337                    }
338                }
339            }
340            None => {
341                let fut = self.service.call(req);
342                return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
343            }
344        };
345
346        // Check rate limit
347        let state = self.state.clone();
348        let service = self.service.clone();
349
350        Box::pin(async move {
351            match state.check_rate_limit(&user_id) {
352                Ok(_) => {
353                    // Rate limit not exceeded, proceed with request
354                    service.call(req).await.map(|res| res.map_into_left_body())
355                }
356                Err(msg) => {
357                    // Rate limit exceeded, return 429
358                    let retry_after = state.config.window_duration.as_secs().to_string();
359                    let response = HttpResponse::build(StatusCode::TOO_MANY_REQUESTS)
360                        .insert_header(("Retry-After", retry_after.clone()))
361                        .json(serde_json::json!({
362                            "error": msg,
363                            "retry_after_seconds": state.config.window_duration.as_secs()
364                        }));
365
366                    Ok(req.into_response(response).map_into_right_body())
367                }
368            }
369        })
370    }
371}
372
373// ========================================
374// Global Rate Limiting (Issue #78)
375// ========================================
376//
377// IP-based rate limiting (public endpoints + /auth/login brute-force
378// protection) is enforced by Traefik middlewares, not the application — see
379// the `traefik.http.middlewares.*.ratelimit.*` labels on the backend service
380// in docker-compose.yml / docker-compose.prod.yml and the koprogo-rate-limit
381// Middleware CRD in infrastructure/_shared/kustomize/base/ingress.yaml.
382// GdprRateLimit above remains application-side because it is per
383// authenticated user (JWT identity), which Traefik cannot see.
384
385// ========================================
386// Request Concurrency Limit (Issue #718)
387// ========================================
388//
389// Constat #718 : sous rafale (2 workers Playwright créant des lots en
390// séquence via `seedConformantUnits()`), une partie des `POST /units` et
391// `GET /acps` sur la démo prod remontait en 502 Bad Gateway ou en timeout
392// client 10-30s. Le pool sqlx (`DB_POOL_MAX_CONNECTIONS=10`,
393// `acquire_timeout=30s`, cf. `infrastructure/database/pool.rs`) absorbe une
394// pointe en faisant *attendre* les requêtes en excès jusqu'à 30s avant
395// d'échouer — un délai qui colle exactement à la fenêtre observée, et qui ne
396// dit rien à l'appelant : a-t-il été pris en compte ou non ?
397//
398// Ce middleware ferme la porte plus tôt et plus clairement : au-delà de
399// `max_concurrent` requêtes en vol simultanément (toutes routes confondues,
400// tous workers Actix confondus puisque le sémaphore est partagé), les
401// suivantes reçoivent un 429 immédiat avec `Retry-After`, plutôt que
402// d'attendre une connexion de pool qui n'arrivera peut-être jamais à temps.
403//
404// Ce n'est ni un remplacement du rate-limit Traefik par IP (abus / brute
405// force), ni du rate-limit GDPR par utilisateur : ce middleware protège la
406// ressource partagée (le pool de connexions, le seul vCPU de la VPS), pas
407// l'identité de l'appelant — d'où son application uniforme, sans
408// distinction d'IP ni de JWT. Le refus est transitoire : dès qu'un slot se
409// libère, la requête suivante repasse — ce n'est pas un bannissement (celui
410// de CrowdSec du 2026-09-01 reste seul juge de l'abus).
411//
412// `/health` est explicitement exclu : une sonde de vivacité étouffée par la
413// charge applicative ferait déclarer le conteneur unhealthy et le ferait
414// redémarrer (`restart: unless-stopped`) — ce qui aggraverait l'incident
415// au lieu de l'absorber.
416//
417// Défaut conservateur (`DEFAULT_MAX_CONCURRENT_REQUESTS`), configurable via
418// `MAX_CONCURRENT_REQUESTS` — à ajuster une fois le rejeu du scénario fait
419// sur la pile de recette (ADR 0050, cf. DoD #718 : « la cause nommée avant
420// tout correctif »). Ce middleware ne tranche pas contention d'hôte vs
421// applicatif ; il rend le refus déterministe quel que soit le verdict, et
422// n'a desserré aucune limite existante (Traefik, GDPR) pour l'obtenir.
423
424/// Conservative starting point: comfortably above the sqlx pool's
425/// `max_connections` default (10, cf. `DB_POOL_MAX_CONNECTIONS`) since a
426/// single request typically issues several short, sequential queries rather
427/// than holding one connection for its whole lifetime — see
428/// `unit_repository_impl.rs`, which acquires per-call via `&self.pool`.
429pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 20;
430
431/// Configuration for [`RequestConcurrencyLimit`].
432#[derive(Clone, Copy, Debug)]
433pub struct ConcurrencyLimitConfig {
434    /// Maximum number of requests allowed in flight at once, across all
435    /// Actix workers.
436    pub max_concurrent: usize,
437    /// Value advertised in the `Retry-After` header when shedding a request.
438    pub retry_after_secs: u64,
439}
440
441impl Default for ConcurrencyLimitConfig {
442    fn default() -> Self {
443        let max_concurrent = std::env::var("MAX_CONCURRENT_REQUESTS")
444            .ok()
445            .and_then(|v| v.parse::<usize>().ok())
446            .filter(|v| *v > 0)
447            .unwrap_or(DEFAULT_MAX_CONCURRENT_REQUESTS);
448        Self {
449            max_concurrent,
450            retry_after_secs: 1,
451        }
452    }
453}
454
455/// Load-shedding middleware: bounds the number of requests in flight and
456/// rejects the excess with an explicit `429 Too Many Requests` +
457/// `Retry-After`, instead of letting them queue silently on the DB pool
458/// until a client timeout or an upstream 502 (see module docs above).
459#[derive(Clone)]
460pub struct RequestConcurrencyLimit {
461    semaphore: Arc<tokio::sync::Semaphore>,
462    retry_after_secs: u64,
463}
464
465impl RequestConcurrencyLimit {
466    pub fn new(config: ConcurrencyLimitConfig) -> Self {
467        Self {
468            semaphore: Arc::new(tokio::sync::Semaphore::new(config.max_concurrent)),
469            retry_after_secs: config.retry_after_secs,
470        }
471    }
472}
473
474impl<S, B> Transform<S, ServiceRequest> for RequestConcurrencyLimit
475where
476    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
477    S::Future: 'static,
478    B: MessageBody + 'static,
479{
480    type Response = ServiceResponse<actix_web::body::EitherBody<B>>;
481    type Error = Error;
482    type InitError = ();
483    type Transform = RequestConcurrencyLimitMiddleware<S>;
484    type Future = Ready<Result<Self::Transform, Self::InitError>>;
485
486    fn new_transform(&self, service: S) -> Self::Future {
487        ready(Ok(RequestConcurrencyLimitMiddleware {
488            service: Arc::new(service),
489            semaphore: self.semaphore.clone(),
490            retry_after_secs: self.retry_after_secs,
491        }))
492    }
493}
494
495pub struct RequestConcurrencyLimitMiddleware<S> {
496    service: Arc<S>,
497    semaphore: Arc<tokio::sync::Semaphore>,
498    retry_after_secs: u64,
499}
500
501impl<S, B> Service<ServiceRequest> for RequestConcurrencyLimitMiddleware<S>
502where
503    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
504    S::Future: 'static,
505    B: MessageBody + 'static,
506{
507    type Response = ServiceResponse<actix_web::body::EitherBody<B>>;
508    type Error = Error;
509    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
510
511    forward_ready!(service);
512
513    fn call(&self, req: ServiceRequest) -> Self::Future {
514        // Liveness probe: never shed (see module docs — an unhealthy
515        // container restarts, which turns backpressure into an outage).
516        if req.path().ends_with("/health") {
517            let fut = self.service.call(req);
518            return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
519        }
520
521        match self.semaphore.clone().try_acquire_owned() {
522            Ok(permit) => {
523                let fut = self.service.call(req);
524                Box::pin(async move {
525                    let res = fut.await;
526                    drop(permit); // frees the slot as soon as the response is ready
527                    res.map(|r| r.map_into_left_body())
528                })
529            }
530            Err(_) => {
531                let retry_after = self.retry_after_secs.to_string();
532                let response = HttpResponse::build(StatusCode::TOO_MANY_REQUESTS)
533                    .insert_header(("Retry-After", retry_after))
534                    .json(serde_json::json!({
535                        "error": "server_busy",
536                        "message": "Trop de requêtes en cours de traitement, réessayez sous peu.",
537                        "retry_after_seconds": self.retry_after_secs,
538                    }));
539                Box::pin(async move { Ok(req.into_response(response).map_into_right_body()) })
540            }
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_authenticated_user_require_organization() {
551        let user_with_org = AuthenticatedUser {
552            user_id: Uuid::new_v4(),
553            email: "test@example.com".to_string(),
554            role: "admin".to_string(),
555            role_id: None,
556            organization_id: Some(Uuid::new_v4()),
557        };
558
559        assert!(user_with_org.require_organization().is_ok());
560
561        let user_without_org = AuthenticatedUser {
562            user_id: Uuid::new_v4(),
563            email: "test@example.com".to_string(),
564            role: "admin".to_string(),
565            role_id: None,
566            organization_id: None,
567        };
568
569        assert!(user_without_org.require_organization().is_err());
570    }
571
572    #[test]
573    fn test_gdpr_rate_limit_config_default() {
574        let config = GdprRateLimitConfig::default();
575        assert_eq!(config.max_requests, 10);
576        assert_eq!(config.window_duration, Duration::from_secs(3600));
577    }
578
579    #[test]
580    fn test_gdpr_rate_limit_state_allows_within_limit() {
581        let config = GdprRateLimitConfig {
582            max_requests: 3,
583            window_duration: Duration::from_secs(60),
584        };
585        let state = GdprRateLimitState::new(config);
586
587        assert!(state.check_rate_limit("user1").is_ok());
588        assert!(state.check_rate_limit("user1").is_ok());
589        assert!(state.check_rate_limit("user1").is_ok());
590    }
591
592    #[test]
593    fn test_gdpr_rate_limit_state_blocks_exceeding_limit() {
594        let config = GdprRateLimitConfig {
595            max_requests: 2,
596            window_duration: Duration::from_secs(60),
597        };
598        let state = GdprRateLimitState::new(config);
599
600        assert!(state.check_rate_limit("user1").is_ok());
601        assert!(state.check_rate_limit("user1").is_ok());
602        let result = state.check_rate_limit("user1");
603        assert!(result.is_err());
604        assert!(result
605            .unwrap_err()
606            .contains("Rate limit exceeded. Try again in"));
607    }
608}