Skip to main content

koprogo_api/infrastructure/web/handlers/
role_assignment_handlers.rs

1//! HTTP handlers for Story B0bis — CRUD REST `role-assignments` (gap Story 3.1).
2//!
3//! Story 3.1 a livré l'entité `UserRoleAssignment` + `UserRoleRepository` +
4//! helpers RBAC, mais n'avait **jamais** exposé un endpoint REST pour
5//! assigner / lister / révoquer un sous-rôle. Cela bloquait toute UI
6//! d'administration (Phase B FE — `RoleAssignmentForm` / `RoleAssignmentList`).
7//!
8//! Routes:
9//! - `POST   /users/{user_id}/role-assignments`
10//! - `GET    /users/{user_id}/role-assignments`
11//! - `DELETE /users/{user_id}/role-assignments/{assignment_id}`
12//! - `GET    /role-assignments?organization_id=&role=` — listing admin filtré.
13//!
14//! Auth (cohérent avec mandate_handlers / role_delegation_handlers) :
15//! - POST    : superadmin OU syndic dans son organization.
16//! - GET     : superadmin OU syndic OU le user lui-même (self).
17//! - DELETE  : superadmin OU syndic-de-l-org.
18//! - GET admin list filtré : superadmin uniquement.
19
20use crate::application::error::AppError;
21use crate::domain::entities::{UserRole, UserRoleAssignment};
22use crate::infrastructure::web::{AppState, AuthenticatedUser};
23use actix_web::{delete, get, post, web, HttpResponse};
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26use std::str::FromStr;
27use uuid::Uuid;
28
29// ---------------------------------------------------------------------------
30// DTOs
31// ---------------------------------------------------------------------------
32
33#[derive(Debug, Deserialize, utoipa::ToSchema)]
34pub struct AssignRoleRequest {
35    /// Role canonique (ex. "accountant.encodeur", "community.moderator").
36    pub role: String,
37    /// Organization scope (None = role global).
38    pub organization_id: Option<Uuid>,
39    /// Si Some, assignment temporaire (delegated) qui expire à cette date.
40    /// Si None, assignment native permanente.
41    pub valid_until: Option<DateTime<Utc>>,
42}
43
44#[derive(Debug, Serialize, utoipa::ToSchema)]
45pub struct UserRoleAssignmentResponse {
46    pub id: Uuid,
47    pub user_id: Uuid,
48    pub role: String,
49    pub organization_id: Option<Uuid>,
50    pub is_primary: bool,
51    pub valid_until: Option<DateTime<Utc>>,
52    pub delegated_from_user_id: Option<Uuid>,
53    pub created_at: DateTime<Utc>,
54    pub updated_at: DateTime<Utc>,
55}
56
57impl From<UserRoleAssignment> for UserRoleAssignmentResponse {
58    fn from(a: UserRoleAssignment) -> Self {
59        Self {
60            id: a.id,
61            user_id: a.user_id,
62            role: a.role.to_string(),
63            organization_id: a.organization_id,
64            is_primary: a.is_primary,
65            valid_until: a.valid_until,
66            delegated_from_user_id: a.delegated_from_user_id,
67            created_at: a.created_at,
68            updated_at: a.updated_at,
69        }
70    }
71}
72
73#[derive(Debug, Deserialize)]
74pub struct ListRoleAssignmentsAdminQuery {
75    pub organization_id: Option<Uuid>,
76    pub role: Option<String>,
77}
78
79// ---------------------------------------------------------------------------
80// Guards
81// ---------------------------------------------------------------------------
82
83/// Le caller peut administrer les role-assignments d'une target :
84/// - superadmin global,
85/// - OU syndic dans l'organization de la target user (si org alignée).
86async fn ensure_can_admin_target(
87    state: &web::Data<AppState>,
88    caller: &AuthenticatedUser,
89    target_user_id: Uuid,
90) -> Result<(), AppError> {
91    if caller.is_superadmin() {
92        return Ok(());
93    }
94    if caller.role != "syndic" {
95        return Err(AppError::Forbidden(
96            "Only superadmin or syndic can administer role assignments".to_string(),
97        ));
98    }
99    // Syndic : vérifier que la target est dans la MÊME organization.
100    let target = state
101        .user_use_cases
102        .list_assignments_for_user(target_user_id)
103        .await?;
104    // On accepte si au moins une assignment partage l'org du caller.
105    let same_org = target
106        .iter()
107        .any(|a| a.organization_id.is_some() && a.organization_id == caller.organization_id);
108    // Cas bootstrap : si la target n'a encore AUCUNE assignment, on autorise le
109    // syndic à créer la première assignment dans sa propre organization.
110    let bootstrap = target.is_empty() && caller.organization_id.is_some();
111    if same_org || bootstrap {
112        Ok(())
113    } else {
114        Err(AppError::Forbidden(
115            "Syndic can only administer role assignments inside their own organization".to_string(),
116        ))
117    }
118}
119
120// ---------------------------------------------------------------------------
121// POST /users/{user_id}/role-assignments
122// ---------------------------------------------------------------------------
123
124#[utoipa::path(
125    post,
126    path = "/users/{user_id}/role-assignments",
127    tag = "RoleAssignment",
128    summary = "Assign a sub-role to a user (Story B0bis — gap Story 3.1)",
129    request_body = AssignRoleRequest,
130    params(("user_id" = Uuid, Path, description = "Target user UUID")),
131    responses(
132        (status = 201, description = "Role assigned", body = UserRoleAssignmentResponse),
133        (status = 400, description = "Validation error (unknown role, past valid_until)"),
134        (status = 403, description = "Forbidden — not superadmin/syndic"),
135        (status = 404, description = "Target user not found"),
136        (status = 409, description = "Role already actively assigned to this user"),
137    ),
138)]
139#[post("/users/{user_id}/role-assignments")]
140pub async fn assign_role(
141    state: web::Data<AppState>,
142    user: AuthenticatedUser,
143    path: web::Path<Uuid>,
144    body: web::Json<AssignRoleRequest>,
145) -> Result<HttpResponse, AppError> {
146    let target_user_id = path.into_inner();
147    ensure_can_admin_target(&state, &user, target_user_id).await?;
148
149    let req = body.into_inner();
150    let role = UserRole::from_str(&req.role).map_err(AppError::Validation)?;
151
152    let saved = state
153        .user_use_cases
154        .assign_role(
155            target_user_id,
156            role,
157            req.organization_id,
158            req.valid_until,
159            user.user_id,
160        )
161        .await?;
162    Ok(HttpResponse::Created().json(UserRoleAssignmentResponse::from(saved)))
163}
164
165// ---------------------------------------------------------------------------
166// GET /users/{user_id}/role-assignments
167// ---------------------------------------------------------------------------
168
169#[utoipa::path(
170    get,
171    path = "/users/{user_id}/role-assignments",
172    tag = "RoleAssignment",
173    summary = "List role assignments for a user",
174    params(("user_id" = Uuid, Path, description = "Target user UUID")),
175    responses(
176        (status = 200, description = "Role assignments", body = Vec<UserRoleAssignmentResponse>),
177        (status = 403, description = "Forbidden"),
178    ),
179)]
180#[get("/users/{user_id}/role-assignments")]
181pub async fn list_role_assignments_for_user(
182    state: web::Data<AppState>,
183    user: AuthenticatedUser,
184    path: web::Path<Uuid>,
185) -> Result<HttpResponse, AppError> {
186    let target_user_id = path.into_inner();
187    let is_self = target_user_id == user.user_id;
188    if !is_self {
189        ensure_can_admin_target(&state, &user, target_user_id).await?;
190    }
191    let rows = state
192        .user_use_cases
193        .list_assignments_for_user(target_user_id)
194        .await?;
195    let response: Vec<UserRoleAssignmentResponse> = rows
196        .into_iter()
197        .map(UserRoleAssignmentResponse::from)
198        .collect();
199    Ok(HttpResponse::Ok().json(response))
200}
201
202// ---------------------------------------------------------------------------
203// DELETE /users/{user_id}/role-assignments/{assignment_id}
204// ---------------------------------------------------------------------------
205
206#[utoipa::path(
207    delete,
208    path = "/users/{user_id}/role-assignments/{assignment_id}",
209    tag = "RoleAssignment",
210    summary = "Revoke a role assignment",
211    params(
212        ("user_id" = Uuid, Path, description = "Target user UUID"),
213        ("assignment_id" = Uuid, Path, description = "Assignment UUID to revoke"),
214    ),
215    responses(
216        (status = 204, description = "Assignment revoked"),
217        (status = 403, description = "Forbidden"),
218        (status = 404, description = "Assignment not found"),
219    ),
220)]
221#[delete("/users/{user_id}/role-assignments/{assignment_id}")]
222pub async fn revoke_role_assignment(
223    state: web::Data<AppState>,
224    user: AuthenticatedUser,
225    path: web::Path<(Uuid, Uuid)>,
226) -> Result<HttpResponse, AppError> {
227    let (target_user_id, assignment_id) = path.into_inner();
228    ensure_can_admin_target(&state, &user, target_user_id).await?;
229    state
230        .user_use_cases
231        .revoke_assignment(assignment_id)
232        .await?;
233    Ok(HttpResponse::NoContent().finish())
234}
235
236// ---------------------------------------------------------------------------
237// GET /role-assignments?organization_id=&role= — admin filtered list
238// ---------------------------------------------------------------------------
239
240#[utoipa::path(
241    get,
242    path = "/role-assignments",
243    tag = "RoleAssignment",
244    summary = "List role assignments filtered by organization and/or role (superadmin only)",
245    params(
246        ("organization_id" = Option<Uuid>, Query, description = "Filter by organization"),
247        ("role" = Option<String>, Query, description = "Filter by role string (whitelist)"),
248    ),
249    responses(
250        (status = 200, description = "Filtered role assignments", body = Vec<UserRoleAssignmentResponse>),
251        (status = 403, description = "Forbidden — superadmin only"),
252    ),
253)]
254#[get("/role-assignments")]
255pub async fn list_role_assignments_admin(
256    state: web::Data<AppState>,
257    user: AuthenticatedUser,
258    query: web::Query<ListRoleAssignmentsAdminQuery>,
259) -> Result<HttpResponse, AppError> {
260    if !user.is_superadmin() {
261        return Err(AppError::Forbidden(
262            "Only superadmin can list cross-user role assignments".to_string(),
263        ));
264    }
265    let q = query.into_inner();
266    // Validate role if provided (refuse arbitrary strings — INV cohérent
267    // avec UserRole::from_str whitelist).
268    let role_filter = match q.role.as_deref() {
269        None => None,
270        Some(s) => Some(UserRole::from_str(s).map_err(AppError::Validation)?),
271    };
272
273    // No bulk admin query exists in the trait — we list per-user via
274    // `list_all` then filter. SuperAdmin is the only caller; the cost is
275    // acceptable for an admin tool. A dedicated repo method can be added
276    // later if perf becomes a concern.
277    let all_users = state
278        .user_use_cases
279        .list_all()
280        .await
281        .map_err(AppError::from)?;
282    let mut rows: Vec<UserRoleAssignmentResponse> = Vec::new();
283    for u in all_users {
284        let uid = Uuid::parse_str(&u.id).map_err(|e| AppError::Internal(e.to_string()))?;
285        let assignments = state.user_use_cases.list_assignments_for_user(uid).await?;
286        for a in assignments {
287            let role_match = match &role_filter {
288                None => true,
289                Some(r) => &a.role == r,
290            };
291            let org_match = match q.organization_id {
292                None => true,
293                Some(org) => a.organization_id == Some(org),
294            };
295            if role_match && org_match {
296                rows.push(UserRoleAssignmentResponse::from(a));
297            }
298        }
299    }
300    Ok(HttpResponse::Ok().json(rows))
301}