Skip to main content

koprogo_api/infrastructure/web/middleware/
community_access_guard.rs

1//! `community_access_guard` middleware — Story 5.5 (refonte UX multi-rôle ACP).
2//!
3//! Contrepartie serveur de l'ADR 0052 : le comptable (`accountant`,
4//! `accountant.encodeur`, `accountant.emetteur`) est un prestataire, pas un
5//! copropriétaire — il n'a pas accès aux routes communautaires (SEL,
6//! sondages, annonces, objets partagés, réservations de ressources
7//! communes), sauf s'il cumule aussi un rôle `owner` sur l'organisation
8//! (auquel cas il y accède via CE rôle-là, pas via sa casquette comptable).
9//!
10//! FR/INV : FR28, FR30 ; INV-6. Deps : Story 5.3 (#587). Refs : #556.
11//!
12//! Design :
13//! - Il n'existe pas de scope Actix `/community` dédié — les routes SEL /
14//!   sondages / annonces / objets partagés / réservations vivent à plat sous
15//!   `/api/v1` (cf. `routes.rs`). Comme `GdprRateLimit`, ce garde est donc
16//!   câblé GLOBALEMENT dans `main.rs` et s'auto-filtre par préfixe de
17//!   chemin plutôt que par un `.wrap()` scopé sur un sous-arbre de routes.
18//! - JWT absent/invalide : laisse filer la requête — l'extracteur
19//!   `AuthenticatedUser` du handler renverra son propre 401 ; ce garde n'a
20//!   pas à dupliquer cette vérification.
21//! - Erreur de lecture des rôles cumulés (DB indisponible) : refuse plutôt
22//!   qu'ignorer — même convention que les gardes `verify_*_org_access` de
23//!   `scope_guard.rs` (« refusé plutôt qu'ignoré »).
24
25use std::future::{ready, Future, Ready};
26use std::pin::Pin;
27use std::sync::Arc;
28
29use actix_web::{
30    body::{EitherBody, MessageBody},
31    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
32    web, Error, HttpResponse,
33};
34use serde_json::json;
35use uuid::Uuid;
36
37use crate::domain::entities::{UserRole, UserRoleAssignment};
38use crate::infrastructure::web::app_state::AppState;
39
40// ============================================================================
41// Pure helpers (testés sans machinerie actix — cf. convention scope_guard.rs).
42// ============================================================================
43
44/// Marqueurs de chemin identifiant une route "communautaire" (SEL, sondages,
45/// annonces, objets partagés, réservations de ressources communes).
46///
47/// Pas de scope Actix `/community` : ces routes vivent à plat sous
48/// `/api/v1/{exchanges,polls,notices,shared-objects,resource-bookings}` et
49/// leurs variantes `/buildings/{id}/...` ou `/owners/{id}/...` (cf.
50/// `routes.rs`). Liste explicite plutôt que motif générique : mieux vaut
51/// oublier une route (visible en revue, corrigible) que sur-filtrer une
52/// route non communautaire par coïncidence de nom.
53const COMMUNITY_PATH_MARKERS: &[&str] = &[
54    "/exchanges",
55    "/polls",
56    "/notices",
57    "/shared-objects",
58    "/resource-bookings",
59    "/sel-statistics",
60    "/leaderboard",
61    "/credit-balance",
62    "/exchange-summary",
63];
64
65/// `true` si le chemin correspond à une route communautaire protégée.
66pub fn is_community_path(path: &str) -> bool {
67    COMMUNITY_PATH_MARKERS
68        .iter()
69        .any(|marker| path.contains(marker))
70}
71
72/// Décide si l'accès communautaire doit être refusé pour ce jeu
73/// d'assignments de rôles (les assignments expirées — Story 3.5 délégations
74/// — sont ignorées).
75///
76/// Règle (FR28/FR30, INV-6) : refusé si et seulement si l'appelant cumule au
77/// moins une casquette comptable ET aucun rôle `owner`. Les DEUX sous-rôles
78/// comptables sont exclus, encodeur comme émetteur — n'en exclure qu'un
79/// laisserait la porte ouverte par l'autre.
80pub fn community_access_denied(assignments: &[UserRoleAssignment]) -> bool {
81    let active: Vec<&UserRoleAssignment> = assignments
82        .iter()
83        .filter(|a| a.is_currently_active())
84        .collect();
85
86    let has_owner = active.iter().any(|a| a.role == UserRole::Owner);
87    if has_owner {
88        return false;
89    }
90
91    active.iter().any(|a| a.role.is_accountant())
92}
93
94fn forbidden_response() -> HttpResponse {
95    HttpResponse::Forbidden().json(json!({
96        "error": "Accès communauté réservé aux copropriétaires — le comptable n'y a pas accès (ADR 0052, INV-6)",
97        "kind": "community_access_forbidden",
98    }))
99}
100
101// ============================================================================
102// Actix middleware
103// ============================================================================
104
105/// `CommunityAccessGuard` — factory Actix, à câbler GLOBALEMENT (cf. design
106/// ci-dessus). Wrap :
107/// ```rust,ignore
108/// use actix_web::App;
109/// use koprogo_api::infrastructure::web::middleware::CommunityAccessGuard;
110///
111/// App::new().wrap(CommunityAccessGuard::new());
112/// ```
113#[derive(Clone, Default)]
114pub struct CommunityAccessGuard;
115
116impl CommunityAccessGuard {
117    pub fn new() -> Self {
118        Self
119    }
120}
121
122impl<S, B> Transform<S, ServiceRequest> for CommunityAccessGuard
123where
124    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
125    S::Future: 'static,
126    B: MessageBody + 'static,
127{
128    type Response = ServiceResponse<EitherBody<B>>;
129    type Error = Error;
130    type InitError = ();
131    type Transform = CommunityAccessGuardMiddleware<S>;
132    type Future = Ready<Result<Self::Transform, Self::InitError>>;
133
134    fn new_transform(&self, service: S) -> Self::Future {
135        ready(Ok(CommunityAccessGuardMiddleware {
136            service: Arc::new(service),
137        }))
138    }
139}
140
141pub struct CommunityAccessGuardMiddleware<S> {
142    service: Arc<S>,
143}
144
145impl<S, B> Service<ServiceRequest> for CommunityAccessGuardMiddleware<S>
146where
147    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
148    S::Future: 'static,
149    B: MessageBody + 'static,
150{
151    type Response = ServiceResponse<EitherBody<B>>;
152    type Error = Error;
153    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
154
155    forward_ready!(service);
156
157    fn call(&self, req: ServiceRequest) -> Self::Future {
158        let path = req.path().to_string();
159
160        if !is_community_path(&path) {
161            let fut = self.service.call(req);
162            return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
163        }
164
165        let app_state = match req.app_data::<web::Data<AppState>>() {
166            Some(s) => s.clone(),
167            None => {
168                let fut = self.service.call(req);
169                return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
170            }
171        };
172
173        let auth_header = req
174            .headers()
175            .get("Authorization")
176            .and_then(|h| h.to_str().ok())
177            .map(|s| s.to_string());
178
179        let token = match auth_header.as_deref() {
180            Some(h) if h.starts_with("Bearer ") => {
181                h.trim_start_matches("Bearer ").trim().to_string()
182            }
183            _ => {
184                // Pas de JWT exploitable : laisse le handler / l'extracteur
185                // AuthenticatedUser renvoyer son propre 401.
186                let fut = self.service.call(req);
187                return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
188            }
189        };
190
191        let claims = match app_state.auth_use_cases.verify_token(&token) {
192            Ok(c) => c,
193            Err(_) => {
194                let fut = self.service.call(req);
195                return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
196            }
197        };
198
199        let user_id = match Uuid::parse_str(&claims.sub) {
200            Ok(u) => u,
201            Err(_) => {
202                let fut = self.service.call(req);
203                return Box::pin(async move { fut.await.map(|res| res.map_into_left_body()) });
204            }
205        };
206
207        let service = self.service.clone();
208
209        Box::pin(async move {
210            let assignments = match app_state
211                .user_use_cases
212                .list_assignments_for_user(user_id)
213                .await
214            {
215                Ok(list) => list,
216                Err(_) => {
217                    // Refusé plutôt qu'ignoré — même convention que les
218                    // gardes verify_*_org_access de scope_guard.rs.
219                    let resp = req.into_response(forbidden_response().map_into_right_body());
220                    return Ok(resp);
221                }
222            };
223
224            if community_access_denied(&assignments) {
225                let resp = req.into_response(forbidden_response().map_into_right_body());
226                return Ok(resp);
227            }
228
229            let res = service.call(req).await?;
230            Ok(res.map_into_left_body())
231        })
232    }
233}
234
235// ============================================================================
236// Tests — taxonomie 4-cat (CRITICAL.md §3). Pure helpers only ; l'intégration
237// Transform (parsing JWT + lookup DB) est exercée par le harnais BDD
238// `tests/features/community_accountant_forbidden.feature`.
239// ============================================================================
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use chrono::{Duration, Utc};
245
246    fn assignment(role: UserRole) -> UserRoleAssignment {
247        UserRoleAssignment::new(Uuid::new_v4(), role, None, true)
248    }
249
250    // ----- @happy ----------------------------------------------------------
251
252    #[test]
253    fn happy_owner_only_is_allowed() {
254        let assignments = vec![assignment(UserRole::Owner)];
255        assert!(!community_access_denied(&assignments));
256    }
257
258    #[test]
259    fn happy_board_member_owner_is_allowed() {
260        // Catherine, conseillère : accède via son rôle owner sous-jacent.
261        let user_id = Uuid::new_v4();
262        let assignments = vec![
263            UserRoleAssignment::new(user_id, UserRole::Owner, None, true),
264            UserRoleAssignment::new(user_id, UserRole::BoardMember, None, false),
265        ];
266        assert!(!community_access_denied(&assignments));
267    }
268
269    #[test]
270    fn happy_community_path_matches_known_routes() {
271        assert!(is_community_path("/api/v1/exchanges"));
272        assert!(is_community_path("/api/v1/polls"));
273        assert!(is_community_path("/api/v1/buildings/123/notices"));
274        assert!(is_community_path("/api/v1/shared-objects/my-borrowed"));
275        assert!(is_community_path("/api/v1/resource-bookings"));
276    }
277
278    // ----- @edge -------------------------------------------------------------
279
280    #[test]
281    fn edge_accountant_encodeur_cumulating_owner_is_allowed() {
282        // Paul, comptable encodeur, cumule le rôle owner → accède via owner.
283        let user_id = Uuid::new_v4();
284        let assignments = vec![
285            UserRoleAssignment::new(user_id, UserRole::AccountantEncodeur, None, true),
286            UserRoleAssignment::new(user_id, UserRole::Owner, None, false),
287        ];
288        assert!(!community_access_denied(&assignments));
289    }
290
291    #[test]
292    fn edge_expired_owner_delegation_does_not_grant_access() {
293        // Une délégation owner expirée (Story 3.5) ne compte pas comme cumul :
294        // le comptable pur redevient refusé dès que la fenêtre est passée.
295        let user_id = Uuid::new_v4();
296        let expired_owner = UserRoleAssignment::new_delegated(
297            user_id,
298            UserRole::Owner,
299            None,
300            Utc::now() - Duration::seconds(1),
301            Uuid::new_v4(),
302        );
303        let assignments = vec![
304            UserRoleAssignment::new(user_id, UserRole::AccountantEmetteur, None, true),
305            expired_owner,
306        ];
307        assert!(community_access_denied(&assignments));
308    }
309
310    // ----- @security -----------------------------------------------------------
311
312    #[test]
313    fn security_pure_accountant_emetteur_is_denied() {
314        // Pierre, comptable émetteur pur sans rôle owner → 403 (INV-6).
315        let assignments = vec![assignment(UserRole::AccountantEmetteur)];
316        assert!(community_access_denied(&assignments));
317    }
318
319    #[test]
320    fn security_pure_accountant_encodeur_is_denied() {
321        // Symétrique : l'encodeur pur est exclu aussi (les DEUX sous-rôles).
322        let assignments = vec![assignment(UserRole::AccountantEncodeur)];
323        assert!(community_access_denied(&assignments));
324    }
325
326    #[test]
327    fn security_generic_accountant_without_owner_is_denied() {
328        let assignments = vec![assignment(UserRole::Accountant)];
329        assert!(community_access_denied(&assignments));
330    }
331
332    // ----- @negative -----------------------------------------------------------
333
334    #[test]
335    fn negative_no_assignments_is_not_denied_by_this_guard() {
336        // Hors périmètre de ce garde : un appelant sans aucune assignment
337        // n'est pas un comptable, donc pas concerné par l'exclusion INV-6.
338        // D'autres gardes couvrent l'authentification/l'autorisation générale.
339        assert!(!community_access_denied(&[]));
340    }
341
342    #[test]
343    fn negative_non_community_path_is_not_matched() {
344        assert!(!is_community_path("/api/v1/buildings/123/expenses"));
345        assert!(!is_community_path("/api/v1/units"));
346    }
347}