koprogo_api/infrastructure/web/handlers/
role_delegation_handlers.rs1use crate::application::error::AppError;
14use crate::domain::entities::{UserRole, UserRoleAssignment};
15use crate::infrastructure::web::{AppState, AuthenticatedUser};
16use actix_web::{delete, get, post, web, HttpResponse};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use std::str::FromStr;
20use uuid::Uuid;
21
22#[derive(Debug, Deserialize, utoipa::ToSchema)]
27pub struct DelegateRoleRequest {
28 pub target_user_id: Uuid,
29 pub role: String,
30 pub organization_id: Option<Uuid>,
31 pub valid_until: DateTime<Utc>,
32}
33
34#[derive(Debug, Serialize, utoipa::ToSchema)]
35pub struct RoleDelegationResponse {
36 pub id: Uuid,
37 pub user_id: Uuid,
38 pub role: String,
39 pub organization_id: Option<Uuid>,
40 pub valid_until: Option<DateTime<Utc>>,
41 pub delegated_from_user_id: Option<Uuid>,
42 pub created_at: DateTime<Utc>,
43 pub updated_at: DateTime<Utc>,
44}
45
46impl From<UserRoleAssignment> for RoleDelegationResponse {
47 fn from(a: UserRoleAssignment) -> Self {
48 Self {
49 id: a.id,
50 user_id: a.user_id,
51 role: a.role.to_string(),
52 organization_id: a.organization_id,
53 valid_until: a.valid_until,
54 delegated_from_user_id: a.delegated_from_user_id,
55 created_at: a.created_at,
56 updated_at: a.updated_at,
57 }
58 }
59}
60
61#[derive(Debug, Deserialize)]
62pub struct ListDelegationsQuery {
63 pub subject: Option<Uuid>,
64}
65
66fn caller_must_hold_role(user: &AuthenticatedUser, role: &UserRole) -> Result<(), AppError> {
74 if user.role == role.to_string() {
75 return Ok(());
76 }
77 if user.is_superadmin() {
79 return Ok(());
80 }
81 Err(AppError::Forbidden(format!(
82 "Caller does not hold role '{}'",
83 role
84 )))
85}
86
87#[utoipa::path(
92 post,
93 path = "/role-delegations",
94 tag = "RoleDelegation",
95 summary = "Delegate a role to another user for a bounded duration",
96 responses(
97 (status = 201, description = "Delegation created", body = RoleDelegationResponse),
98 (status = 400, description = "Validation error"),
99 (status = 403, description = "Forbidden — caller cannot delegate this role"),
100 (status = 409, description = "Target already holds the role"),
101 ),
102)]
103#[post("/role-delegations")]
104pub async fn create_role_delegation(
105 state: web::Data<AppState>,
106 user: AuthenticatedUser,
107 body: web::Json<DelegateRoleRequest>,
108) -> Result<HttpResponse, AppError> {
109 let req = body.into_inner();
110 let role = UserRole::from_str(&req.role).map_err(AppError::Validation)?;
111 caller_must_hold_role(&user, &role)?;
112
113 let delegation = state
114 .role_delegation_use_cases
115 .delegate_role(
116 user.user_id,
117 req.target_user_id,
118 role,
119 req.organization_id,
120 req.valid_until,
121 )
122 .await?;
123
124 Ok(HttpResponse::Created().json(RoleDelegationResponse::from(delegation)))
125}
126
127#[utoipa::path(
132 delete,
133 path = "/role-delegations/{id}",
134 tag = "RoleDelegation",
135 summary = "Revoke a delegation before its natural expiry",
136 responses(
137 (status = 204, description = "Delegation revoked"),
138 (status = 403, description = "Forbidden"),
139 (status = 404, description = "Delegation not found"),
140 ),
141)]
142#[delete("/role-delegations/{id}")]
143pub async fn revoke_role_delegation(
144 state: web::Data<AppState>,
145 user: AuthenticatedUser,
146 path: web::Path<Uuid>,
147) -> Result<HttpResponse, AppError> {
148 let id = path.into_inner();
149 let existing = state
152 .role_delegation_use_cases
153 .list_delegations_of(user.user_id)
154 .await?;
155 let is_admin = user.is_superadmin();
156 let is_owner_of_delegation = existing.iter().any(|a| {
157 a.id == id && (a.delegated_from_user_id == Some(user.user_id) || a.user_id == user.user_id)
158 });
159 if !is_admin && !is_owner_of_delegation {
160 return Err(AppError::Forbidden(
161 "Only the delegator, the subject or a superadmin can revoke".to_string(),
162 ));
163 }
164 state
165 .role_delegation_use_cases
166 .revoke_delegation(id)
167 .await?;
168 Ok(HttpResponse::NoContent().finish())
169}
170
171#[utoipa::path(
176 get,
177 path = "/role-delegations",
178 tag = "RoleDelegation",
179 summary = "List active delegations involving a subject user",
180 params(
181 ("subject" = Option<Uuid>, Query, description = "Subject user id. Defaults to the caller.")
182 ),
183 responses(
184 (status = 200, description = "Active delegations", body = Vec<RoleDelegationResponse>),
185 (status = 403, description = "Forbidden — caller cannot view this subject's delegations"),
186 ),
187)]
188#[get("/role-delegations")]
189pub async fn list_role_delegations(
190 state: web::Data<AppState>,
191 user: AuthenticatedUser,
192 query: web::Query<ListDelegationsQuery>,
193) -> Result<HttpResponse, AppError> {
194 let subject = query.into_inner().subject.unwrap_or(user.user_id);
195 let is_self = subject == user.user_id;
196 let is_admin = matches!(user.role.as_str(), "syndic" | "superadmin");
197 if !is_self && !is_admin {
198 return Err(AppError::Forbidden(
199 "Only the subject themselves or a syndic/superadmin can list delegations".to_string(),
200 ));
201 }
202 let delegations = state
203 .role_delegation_use_cases
204 .list_delegations_of(subject)
205 .await?;
206 let response: Vec<RoleDelegationResponse> = delegations
207 .into_iter()
208 .map(RoleDelegationResponse::from)
209 .collect();
210 Ok(HttpResponse::Ok().json(response))
211}