koprogo_api/infrastructure/web/middleware/
community_access_guard.rs1use 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
40const 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
65pub fn is_community_path(path: &str) -> bool {
67 COMMUNITY_PATH_MARKERS
68 .iter()
69 .any(|marker| path.contains(marker))
70}
71
72pub 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#[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 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 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#[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 #[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 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 #[test]
281 fn edge_accountant_encodeur_cumulating_owner_is_allowed() {
282 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 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 #[test]
313 fn security_pure_accountant_emetteur_is_denied() {
314 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 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 #[test]
335 fn negative_no_assignments_is_not_denied_by_this_guard() {
336 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}