Skip to main content

mockforge_http/handlers/
access_review.rs

1//! Access review API handlers
2//!
3//! **Internal / API-only.** No admin UI consumes these endpoints. They are
4//! intended for programmatic compliance tooling (scheduled scripts, SIEM
5//! integrations, external GRC platforms). Do not build speculative UI for
6//! these routes without a stakeholder-defined use case.
7//!
8//! Provides HTTP endpoints for managing access reviews, including:
9//! - Listing reviews
10//! - Approving/revoking access
11//! - Getting review reports
12//! - Starting reviews
13
14use axum::{
15    extract::{Path, State},
16    http::StatusCode,
17    response::Json,
18};
19use mockforge_core::security::{
20    access_review::{AccessReview, ReviewType, UserReviewItem},
21    access_review_service::AccessReviewService,
22    emit_security_event, EventActor, EventOutcome, EventTarget, SecurityEvent, SecurityEventType,
23};
24use serde::{Deserialize, Serialize};
25use std::sync::Arc;
26use tokio::sync::RwLock;
27use tracing::{error, info};
28use uuid::Uuid;
29
30use crate::handlers::auth_helpers::{extract_user_id_with_fallback, OptionalAuthClaims};
31
32/// State for access review handlers
33#[derive(Clone)]
34pub struct AccessReviewState {
35    /// Access review service
36    pub service: Arc<RwLock<AccessReviewService>>,
37}
38
39/// Request to approve access in a review
40#[derive(Debug, Deserialize)]
41pub struct ApproveAccessRequest {
42    /// User ID to approve
43    pub user_id: Uuid,
44    /// Whether access is approved
45    pub approved: bool,
46    /// Justification for approval
47    pub justification: Option<String>,
48}
49
50/// Request to revoke access in a review
51#[derive(Debug, Deserialize)]
52pub struct RevokeAccessRequest {
53    /// User ID to revoke
54    pub user_id: Uuid,
55    /// Reason for revocation
56    pub reason: String,
57}
58
59/// Request to update permissions in a review
60#[derive(Debug, Deserialize)]
61pub struct UpdatePermissionsRequest {
62    /// User ID to update
63    pub user_id: Uuid,
64    /// New roles for the user
65    pub roles: Vec<String>,
66    /// New permissions for the user
67    pub permissions: Vec<String>,
68    /// Reason for permission update
69    pub reason: Option<String>,
70}
71
72/// Response for review list
73#[derive(Debug, Serialize)]
74pub struct ReviewListResponse {
75    /// List of reviews
76    pub reviews: Vec<ReviewSummary>,
77}
78
79/// Review summary (simplified review for list view)
80#[derive(Debug, Serialize)]
81pub struct ReviewSummary {
82    /// Review ID
83    pub review_id: String,
84    /// Review type
85    pub review_type: String,
86    /// Review status
87    pub status: String,
88    /// Due date
89    pub due_date: chrono::DateTime<chrono::Utc>,
90    /// Total items count
91    pub items_count: u32,
92    /// Pending approvals count
93    pub pending_approvals: u32,
94}
95
96/// Response for review details
97#[derive(Debug, Serialize)]
98pub struct ReviewDetailResponse {
99    /// Review details
100    #[serde(flatten)]
101    pub review: AccessReview,
102    /// Review items (if user access review)
103    pub items: Option<Vec<UserReviewItem>>,
104}
105
106/// Response for approve/revoke operations
107#[derive(Debug, Serialize)]
108pub struct ReviewActionResponse {
109    /// Review ID
110    pub review_id: String,
111    /// User ID
112    pub user_id: Uuid,
113    /// Action status
114    pub status: String,
115    /// Action timestamp
116    pub timestamp: chrono::DateTime<chrono::Utc>,
117    /// Additional message
118    pub message: Option<String>,
119}
120
121/// Get all access reviews
122///
123/// GET /api/v1/security/access-reviews
124pub async fn list_reviews(
125    State(state): State<AccessReviewState>,
126) -> Result<Json<ReviewListResponse>, StatusCode> {
127    let service = state.service.read().await;
128    let reviews = service.get_all_reviews();
129
130    let summaries: Vec<ReviewSummary> = reviews
131        .iter()
132        .map(|review| ReviewSummary {
133            review_id: review.review_id.clone(),
134            review_type: format!("{:?}", review.review_type),
135            status: format!("{:?}", review.status),
136            due_date: review.due_date,
137            items_count: review.total_items,
138            pending_approvals: review.pending_approvals,
139        })
140        .collect();
141
142    Ok(Json(ReviewListResponse { reviews: summaries }))
143}
144
145/// Get a specific access review
146///
147/// GET /api/v1/security/access-reviews/{review_id}
148pub async fn get_review(
149    State(state): State<AccessReviewState>,
150    Path(review_id): Path<String>,
151) -> Result<Json<ReviewDetailResponse>, StatusCode> {
152    let service = state.service.read().await;
153
154    let review = service
155        .get_review(&review_id)
156        .ok_or_else(|| {
157            error!("Review {} not found", review_id);
158            StatusCode::NOT_FOUND
159        })?
160        .clone();
161
162    // Get review items if available
163    let items = service
164        .engine()
165        .get_review_items(&review_id)
166        .map(|items_map| items_map.values().cloned().collect());
167
168    Ok(Json(ReviewDetailResponse { review, items }))
169}
170
171/// Approve access in a review
172///
173/// POST /api/v1/security/access-reviews/{review_id}/approve
174pub async fn approve_access(
175    State(state): State<AccessReviewState>,
176    Path(review_id): Path<String>,
177    claims: OptionalAuthClaims,
178    Json(request): Json<ApproveAccessRequest>,
179) -> Result<Json<ReviewActionResponse>, StatusCode> {
180    let mut service = state.service.write().await;
181
182    // Extract approver ID from authentication claims, or use default for mock server
183    let approver_id = extract_user_id_with_fallback(&claims);
184
185    match service
186        .approve_user_access(&review_id, request.user_id, approver_id, request.justification)
187        .await
188    {
189        Ok(()) => {
190            info!("Access approved for user {} in review {}", request.user_id, review_id);
191
192            // Emit security event
193            let event = SecurityEvent::new(SecurityEventType::AuthzAccessGranted, None, None)
194                .with_actor(EventActor {
195                    user_id: Some(approver_id.to_string()),
196                    username: None,
197                    ip_address: None,
198                    user_agent: None,
199                })
200                .with_target(EventTarget {
201                    resource_type: Some("access_review".to_string()),
202                    resource_id: Some(review_id.clone()),
203                    method: None,
204                })
205                .with_outcome(EventOutcome {
206                    success: true,
207                    reason: Some("Access approved in review".to_string()),
208                })
209                .with_metadata("user_id".to_string(), serde_json::json!(request.user_id));
210            emit_security_event(event).await;
211
212            Ok(Json(ReviewActionResponse {
213                review_id,
214                user_id: request.user_id,
215                status: "approved".to_string(),
216                timestamp: chrono::Utc::now(),
217                message: Some("Access approved successfully".to_string()),
218            }))
219        }
220        Err(e) => {
221            error!("Failed to approve access: {}", e);
222            Err(StatusCode::BAD_REQUEST)
223        }
224    }
225}
226
227/// Revoke access in a review
228///
229/// POST /api/v1/security/access-reviews/{review_id}/revoke
230pub async fn revoke_access(
231    State(state): State<AccessReviewState>,
232    Path(review_id): Path<String>,
233    claims: OptionalAuthClaims,
234    Json(request): Json<RevokeAccessRequest>,
235) -> Result<Json<ReviewActionResponse>, StatusCode> {
236    let mut service = state.service.write().await;
237
238    // Extract revoker ID from authentication claims, or use default for mock server
239    let revoker_id = extract_user_id_with_fallback(&claims);
240
241    match service
242        .revoke_user_access(&review_id, request.user_id, revoker_id, request.reason.clone())
243        .await
244    {
245        Ok(()) => {
246            info!("Access revoked for user {} in review {}", request.user_id, review_id);
247
248            // Emit security event
249            let event = SecurityEvent::new(SecurityEventType::AccessUserSuspended, None, None)
250                .with_actor(EventActor {
251                    user_id: Some(revoker_id.to_string()),
252                    username: None,
253                    ip_address: None,
254                    user_agent: None,
255                })
256                .with_target(EventTarget {
257                    resource_type: Some("access_review".to_string()),
258                    resource_id: Some(review_id.clone()),
259                    method: None,
260                })
261                .with_outcome(EventOutcome {
262                    success: true,
263                    reason: Some(request.reason.clone()),
264                })
265                .with_metadata("user_id".to_string(), serde_json::json!(request.user_id))
266                .with_metadata("review_id".to_string(), serde_json::json!(review_id));
267            emit_security_event(event).await;
268
269            Ok(Json(ReviewActionResponse {
270                review_id,
271                user_id: request.user_id,
272                status: "revoked".to_string(),
273                timestamp: chrono::Utc::now(),
274                message: Some(format!("Access revoked: {}", request.reason)),
275            }))
276        }
277        Err(e) => {
278            error!("Failed to revoke access: {}", e);
279            Err(StatusCode::BAD_REQUEST)
280        }
281    }
282}
283
284/// Update user permissions in a review
285///
286/// POST /api/v1/security/access-reviews/{review_id}/update-permissions
287pub async fn update_permissions(
288    State(state): State<AccessReviewState>,
289    Path(review_id): Path<String>,
290    claims: OptionalAuthClaims,
291    Json(request): Json<UpdatePermissionsRequest>,
292) -> Result<Json<ReviewActionResponse>, StatusCode> {
293    let mut service = state.service.write().await;
294
295    // Extract updater ID from authentication claims, or use default for mock server
296    let updater_id = extract_user_id_with_fallback(&claims);
297
298    match service
299        .update_user_permissions(
300            &review_id,
301            request.user_id,
302            updater_id,
303            request.roles.clone(),
304            request.permissions.clone(),
305            request.reason.clone(),
306        )
307        .await
308    {
309        Ok(()) => {
310            info!("Permissions updated for user {} in review {}", request.user_id, review_id);
311
312            // Emit security event for permission change
313            let event = SecurityEvent::new(SecurityEventType::AuthzPermissionChanged, None, None)
314                .with_actor(EventActor {
315                    user_id: Some(updater_id.to_string()),
316                    username: None,
317                    ip_address: None,
318                    user_agent: None,
319                })
320                .with_target(EventTarget {
321                    resource_type: Some("access_review".to_string()),
322                    resource_id: Some(review_id.clone()),
323                    method: None,
324                })
325                .with_outcome(EventOutcome {
326                    success: true,
327                    reason: request.reason.clone(),
328                })
329                .with_metadata("user_id".to_string(), serde_json::json!(request.user_id))
330                .with_metadata("review_id".to_string(), serde_json::json!(review_id))
331                .with_metadata("new_roles".to_string(), serde_json::json!(request.roles))
332                .with_metadata(
333                    "new_permissions".to_string(),
334                    serde_json::json!(request.permissions),
335                );
336            emit_security_event(event).await;
337
338            Ok(Json(ReviewActionResponse {
339                review_id,
340                user_id: request.user_id,
341                status: "permissions_updated".to_string(),
342                timestamp: chrono::Utc::now(),
343                message: Some("Permissions updated successfully".to_string()),
344            }))
345        }
346        Err(e) => {
347            error!("Failed to update permissions: {}", e);
348            Err(StatusCode::BAD_REQUEST)
349        }
350    }
351}
352
353/// Get review report
354///
355/// GET /api/v1/security/access-reviews/{review_id}/report
356pub async fn get_review_report(
357    State(state): State<AccessReviewState>,
358    Path(review_id): Path<String>,
359) -> Result<Json<serde_json::Value>, StatusCode> {
360    let service = state.service.read().await;
361
362    let review = service.get_review(&review_id).ok_or_else(|| {
363        error!("Review {} not found", review_id);
364        StatusCode::NOT_FOUND
365    })?;
366
367    // Convert review to JSON report format
368    let report = serde_json::json!({
369        "review_id": review.review_id,
370        "review_date": review.review_date,
371        "review_type": format!("{:?}", review.review_type),
372        "status": format!("{:?}", review.status),
373        "total_items": review.total_items,
374        "items_reviewed": review.items_reviewed,
375        "findings": review.findings,
376        "actions_taken": review.actions_taken,
377        "pending_reviews": review.pending_approvals,
378        "next_review_date": review.next_review_date,
379    });
380
381    Ok(Json(report))
382}
383
384/// Start a new access review
385///
386/// POST /api/v1/security/access-reviews/start
387pub async fn start_review(
388    State(state): State<AccessReviewState>,
389    Json(request): Json<StartReviewRequest>,
390) -> Result<Json<ReviewDetailResponse>, StatusCode> {
391    let mut service = state.service.write().await;
392
393    // Start review based on type
394    let review_id = match request.review_type {
395        ReviewType::UserAccess => service.start_user_access_review().await.map_err(|e| {
396            error!("Failed to start user access review: {}", e);
397            StatusCode::INTERNAL_SERVER_ERROR
398        })?,
399        ReviewType::PrivilegedAccess => {
400            service.start_privileged_access_review().await.map_err(|e| {
401                error!("Failed to start privileged access review: {}", e);
402                StatusCode::INTERNAL_SERVER_ERROR
403            })?
404        }
405        ReviewType::ApiToken => service.start_token_review().await.map_err(|e| {
406            error!("Failed to start token review: {}", e);
407            StatusCode::INTERNAL_SERVER_ERROR
408        })?,
409        ReviewType::ResourceAccess => {
410            service.start_resource_access_review(Vec::new()).await.map_err(|e| {
411                error!("Failed to start resource access review: {}", e);
412                StatusCode::INTERNAL_SERVER_ERROR
413            })?
414        }
415    };
416
417    info!("Started access review: {}", review_id);
418
419    // Get the review details
420    let review = service
421        .get_review(&review_id)
422        .ok_or_else(|| {
423            error!("Review {} not found after creation", review_id);
424            StatusCode::INTERNAL_SERVER_ERROR
425        })?
426        .clone();
427
428    // Emit security event
429    let event = SecurityEvent::new(SecurityEventType::ComplianceComplianceCheck, None, None)
430        .with_target(EventTarget {
431            resource_type: Some("access_review".to_string()),
432            resource_id: Some(review_id.clone()),
433            method: None,
434        })
435        .with_outcome(EventOutcome {
436            success: true,
437            reason: Some("Access review started".to_string()),
438        });
439    emit_security_event(event).await;
440
441    let items = service
442        .engine()
443        .get_review_items(&review_id)
444        .map(|items_map| items_map.values().cloned().collect());
445
446    Ok(Json(ReviewDetailResponse { review, items }))
447}
448
449/// Request to start a review
450#[derive(Debug, Deserialize)]
451pub struct StartReviewRequest {
452    /// Review type to start
453    pub review_type: ReviewType,
454}
455
456/// Create access review router
457pub fn access_review_router(state: AccessReviewState) -> axum::Router {
458    use axum::routing::{get, post};
459
460    axum::Router::new()
461        .route("/", get(list_reviews))
462        .route("/start", post(start_review))
463        .route("/{review_id}", get(get_review))
464        .route("/{review_id}/approve", post(approve_access))
465        .route("/{review_id}/revoke", post(revoke_access))
466        .route("/{review_id}/update-permissions", post(update_permissions))
467        .route("/{review_id}/report", get(get_review_report))
468        .with_state(state)
469}