Skip to main content

koprogo_api/infrastructure/web/handlers/
role_delegation_handlers.rs

1//! HTTP handlers for Story 3.5 — Temporary role delegation (FR8 INV-8).
2//!
3//! Routes:
4//! - `POST   /role-delegations`        — delegate a role to another user.
5//! - `DELETE /role-delegations/{id}`   — revoke an active delegation.
6//! - `GET    /role-delegations?subject={u}` — list delegations of `subject`
7//!   (admin only OR the subject themselves).
8//!
9//! Auth: the caller MUST hold the role they want to delegate (checked via
10//! their JWT primary role string; the use-case re-checks the *native*
11//! invariant by inspecting persisted assignments).
12
13use 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// ---------------------------------------------------------------------------
23// DTOs
24// ---------------------------------------------------------------------------
25
26#[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
66// ---------------------------------------------------------------------------
67// Guards
68// ---------------------------------------------------------------------------
69
70/// The caller must have the role they want to delegate as their primary role.
71/// The use-case will additionally verify they hold it *natively* (not via a
72/// prior delegation — INV-8 non-transitive).
73fn caller_must_hold_role(user: &AuthenticatedUser, role: &UserRole) -> Result<(), AppError> {
74    if user.role == role.to_string() {
75        return Ok(());
76    }
77    // Superadmin shortcut — Story 3.1 helpers grant blanket authority.
78    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// ---------------------------------------------------------------------------
88// POST /role-delegations
89// ---------------------------------------------------------------------------
90
91#[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// ---------------------------------------------------------------------------
128// DELETE /role-delegations/{id}
129// ---------------------------------------------------------------------------
130
131#[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    // Only the original delegator (or a superadmin) may revoke. We resolve
150    // the row to check the delegator field.
151    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// ---------------------------------------------------------------------------
172// GET /role-delegations?subject={uuid}
173// ---------------------------------------------------------------------------
174
175#[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}