mockforge_core/security/
access_review.rs

1//! Automated access review engine for compliance
2//!
3//! This module provides automated access review functionality for:
4//! - Quarterly user access reviews
5//! - Monthly privileged access reviews
6//! - Monthly API token reviews
7//! - Quarterly resource access reviews
8//!
9//! Compliance: SOC 2 CC6 (Logical Access), ISO 27001 A.9.2 (User Access Management)
10
11use chrono::{DateTime, Duration, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use uuid::Uuid;
15
16/// Review frequency types
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum ReviewFrequency {
20    /// Monthly reviews
21    Monthly,
22    /// Quarterly reviews
23    Quarterly,
24    /// Annual reviews
25    Annually,
26}
27
28impl ReviewFrequency {
29    /// Get the duration for this frequency
30    pub fn duration(&self) -> Duration {
31        match self {
32            ReviewFrequency::Monthly => Duration::days(30),
33            ReviewFrequency::Quarterly => Duration::days(90),
34            ReviewFrequency::Annually => Duration::days(365),
35        }
36    }
37
38    /// Calculate the next review date from a given date
39    pub fn next_review_date(&self, from: DateTime<Utc>) -> DateTime<Utc> {
40        from + self.duration()
41    }
42}
43
44/// Review status
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum ReviewStatus {
48    /// Review is pending (not yet started)
49    Pending,
50    /// Review is in progress
51    InProgress,
52    /// Review is completed
53    Completed,
54    /// Review was cancelled
55    Cancelled,
56}
57
58/// Review type
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ReviewType {
62    /// User access review
63    UserAccess,
64    /// Privileged access review
65    PrivilegedAccess,
66    /// API token review
67    ApiToken,
68    /// Resource access review
69    ResourceAccess,
70}
71
72/// User access information for review
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct UserAccessInfo {
75    /// User ID
76    pub user_id: Uuid,
77    /// Username
78    pub username: String,
79    /// Email address
80    pub email: String,
81    /// Current roles
82    pub roles: Vec<String>,
83    /// Permissions
84    pub permissions: Vec<String>,
85    /// Last login date
86    pub last_login: Option<DateTime<Utc>>,
87    /// Access granted date
88    pub access_granted: DateTime<Utc>,
89    /// Days since last activity
90    pub days_inactive: Option<u64>,
91    /// Whether user is active
92    pub is_active: bool,
93}
94
95/// Privileged access information
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PrivilegedAccessInfo {
98    /// User ID
99    pub user_id: Uuid,
100    /// Username
101    pub username: String,
102    /// Privileged roles
103    pub roles: Vec<String>,
104    /// Whether MFA is enabled
105    pub mfa_enabled: bool,
106    /// Access justification
107    pub justification: Option<String>,
108    /// Justification expiration date
109    pub justification_expires: Option<DateTime<Utc>>,
110    /// Recent privileged actions count
111    pub recent_actions_count: u64,
112    /// Last privileged action date
113    pub last_privileged_action: Option<DateTime<Utc>>,
114}
115
116/// API token information for review
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ApiTokenInfo {
119    /// Token ID
120    pub token_id: String,
121    /// Token name/description
122    pub name: Option<String>,
123    /// Token owner user ID
124    pub owner_id: Uuid,
125    /// Token scopes/permissions
126    pub scopes: Vec<String>,
127    /// Creation date
128    pub created_at: DateTime<Utc>,
129    /// Last usage date
130    pub last_used: Option<DateTime<Utc>>,
131    /// Expiration date
132    pub expires_at: Option<DateTime<Utc>>,
133    /// Days since last use
134    pub days_unused: Option<u64>,
135    /// Whether token is active
136    pub is_active: bool,
137}
138
139/// Resource access information
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ResourceAccessInfo {
142    /// Resource type
143    pub resource_type: String,
144    /// Resource ID
145    pub resource_id: String,
146    /// Users with access
147    pub users_with_access: Vec<Uuid>,
148    /// Access levels
149    pub access_levels: HashMap<Uuid, String>,
150    /// Last access date per user
151    pub last_access: HashMap<Uuid, Option<DateTime<Utc>>>,
152}
153
154/// Review findings
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ReviewFindings {
157    /// Number of inactive users
158    pub inactive_users: u32,
159    /// Number of users with excessive permissions
160    pub excessive_permissions: u32,
161    /// Number of users with no recent access
162    pub no_recent_access: u32,
163    /// Number of privileged users without MFA
164    pub privileged_without_mfa: u32,
165    /// Number of unused tokens
166    pub unused_tokens: u32,
167    /// Number of tokens with excessive scopes
168    pub excessive_scopes: u32,
169    /// Number of tokens expiring soon
170    pub expiring_soon: u32,
171    /// Additional custom findings
172    pub custom: HashMap<String, u32>,
173}
174
175/// Actions taken during review
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct ReviewActions {
178    /// Number of users revoked
179    pub users_revoked: u32,
180    /// Number of permissions reduced
181    pub permissions_reduced: u32,
182    /// Number of MFA enforced
183    pub mfa_enforced: u32,
184    /// Number of tokens revoked
185    pub tokens_revoked: u32,
186    /// Number of tokens rotated
187    pub tokens_rotated: u32,
188    /// Number of scopes reduced
189    pub scopes_reduced: u32,
190    /// Additional custom actions
191    pub custom: HashMap<String, u32>,
192}
193
194/// Access review record
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct AccessReview {
197    /// Review ID
198    pub review_id: String,
199    /// Review type
200    pub review_type: ReviewType,
201    /// Review status
202    pub status: ReviewStatus,
203    /// Review date
204    pub review_date: DateTime<Utc>,
205    /// Due date for completion
206    pub due_date: DateTime<Utc>,
207    /// Total items reviewed
208    pub total_items: u32,
209    /// Items reviewed
210    pub items_reviewed: u32,
211    /// Review findings
212    pub findings: ReviewFindings,
213    /// Actions taken
214    pub actions_taken: ReviewActions,
215    /// Pending approvals count
216    pub pending_approvals: u32,
217    /// Next review date
218    pub next_review_date: DateTime<Utc>,
219    /// Review metadata
220    pub metadata: HashMap<String, serde_json::Value>,
221}
222
223/// User access review item (for approval workflow)
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct UserReviewItem {
226    /// Review ID
227    pub review_id: String,
228    /// User ID
229    pub user_id: Uuid,
230    /// User access information
231    pub access_info: UserAccessInfo,
232    /// Review status (pending, approved, rejected)
233    pub status: String,
234    /// Manager user ID (who should review)
235    pub manager_id: Option<Uuid>,
236    /// Approval deadline
237    pub approval_deadline: Option<DateTime<Utc>>,
238    /// Approved by
239    pub approved_by: Option<Uuid>,
240    /// Approved at
241    pub approved_at: Option<DateTime<Utc>>,
242    /// Rejection reason
243    pub rejection_reason: Option<String>,
244}
245
246/// Access review configuration
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct AccessReviewConfig {
249    /// Whether access review is enabled
250    pub enabled: bool,
251    /// User access review configuration
252    pub user_review: UserReviewConfig,
253    /// Privileged access review configuration
254    pub privileged_review: PrivilegedReviewConfig,
255    /// API token review configuration
256    pub token_review: TokenReviewConfig,
257    /// Resource access review configuration
258    pub resource_review: ResourceReviewConfig,
259    /// Notification configuration
260    pub notifications: NotificationConfig,
261}
262
263/// User access review configuration
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct UserReviewConfig {
266    /// Whether user review is enabled
267    pub enabled: bool,
268    /// Review frequency
269    pub frequency: ReviewFrequency,
270    /// Inactive threshold in days
271    pub inactive_threshold_days: u64,
272    /// Auto-revoke inactive users
273    pub auto_revoke_inactive: bool,
274    /// Require manager approval
275    pub require_manager_approval: bool,
276    /// Approval timeout in days
277    pub approval_timeout_days: u64,
278}
279
280/// Privileged access review configuration
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct PrivilegedReviewConfig {
283    /// Whether privileged review is enabled
284    pub enabled: bool,
285    /// Review frequency
286    pub frequency: ReviewFrequency,
287    /// Require MFA
288    pub require_mfa: bool,
289    /// Require justification
290    pub require_justification: bool,
291    /// Alert on privilege escalation
292    pub alert_on_escalation: bool,
293}
294
295/// API token review configuration
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct TokenReviewConfig {
298    /// Whether token review is enabled
299    pub enabled: bool,
300    /// Review frequency
301    pub frequency: ReviewFrequency,
302    /// Unused threshold in days
303    pub unused_threshold_days: u64,
304    /// Auto-revoke unused tokens
305    pub auto_revoke_unused: bool,
306    /// Rotation threshold in days (before expiration)
307    pub rotation_threshold_days: u64,
308}
309
310/// Resource access review configuration
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct ResourceReviewConfig {
313    /// Whether resource review is enabled
314    pub enabled: bool,
315    /// Review frequency
316    pub frequency: ReviewFrequency,
317    /// List of sensitive resources
318    pub sensitive_resources: Vec<String>,
319}
320
321/// Notification configuration
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct NotificationConfig {
324    /// Whether notifications are enabled
325    pub enabled: bool,
326    /// Notification channels (email, slack, etc.)
327    pub channels: Vec<String>,
328    /// Recipients (security_team, compliance_team, managers)
329    pub recipients: Vec<String>,
330}
331
332impl Default for AccessReviewConfig {
333    fn default() -> Self {
334        Self {
335            enabled: false,
336            user_review: UserReviewConfig {
337                enabled: true,
338                frequency: ReviewFrequency::Quarterly,
339                inactive_threshold_days: 90,
340                auto_revoke_inactive: true,
341                require_manager_approval: true,
342                approval_timeout_days: 30,
343            },
344            privileged_review: PrivilegedReviewConfig {
345                enabled: true,
346                frequency: ReviewFrequency::Monthly,
347                require_mfa: true,
348                require_justification: true,
349                alert_on_escalation: true,
350            },
351            token_review: TokenReviewConfig {
352                enabled: true,
353                frequency: ReviewFrequency::Monthly,
354                unused_threshold_days: 90,
355                auto_revoke_unused: true,
356                rotation_threshold_days: 30,
357            },
358            resource_review: ResourceReviewConfig {
359                enabled: true,
360                frequency: ReviewFrequency::Quarterly,
361                sensitive_resources: vec![
362                    "billing".to_string(),
363                    "user_data".to_string(),
364                    "audit_logs".to_string(),
365                    "security_settings".to_string(),
366                ],
367            },
368            notifications: NotificationConfig {
369                enabled: true,
370                channels: vec!["email".to_string()],
371                recipients: vec!["security_team".to_string(), "compliance_team".to_string()],
372            },
373        }
374    }
375}
376
377/// Access review engine
378///
379/// This engine manages automated access reviews according to the configured schedule
380/// and policies. It generates reports, tracks approvals, and can automatically
381/// revoke access when needed.
382pub struct AccessReviewEngine {
383    config: AccessReviewConfig,
384    /// Active reviews (review_id -> review)
385    active_reviews: HashMap<String, AccessReview>,
386    /// User review items (review_id -> user_id -> item)
387    user_review_items: HashMap<String, HashMap<Uuid, UserReviewItem>>,
388}
389
390impl AccessReviewEngine {
391    /// Create a new access review engine
392    pub fn new(config: AccessReviewConfig) -> Self {
393        Self {
394            config,
395            active_reviews: HashMap::new(),
396            user_review_items: HashMap::new(),
397        }
398    }
399
400    /// Generate a review ID based on type and date
401    pub fn generate_review_id(&self, review_type: ReviewType, date: DateTime<Utc>) -> String {
402        let type_str = match review_type {
403            ReviewType::UserAccess => "user",
404            ReviewType::PrivilegedAccess => "privileged",
405            ReviewType::ApiToken => "token",
406            ReviewType::ResourceAccess => "resource",
407        };
408
409        let date_str = date.format("%Y-%m-%d");
410        format!("review-{}-{}", date_str, type_str)
411    }
412
413    /// Start a user access review
414    ///
415    /// This generates a review report and creates review items for each user
416    /// that needs to be reviewed.
417    pub async fn start_user_access_review(
418        &mut self,
419        users: Vec<UserAccessInfo>,
420    ) -> Result<AccessReview, crate::Error> {
421        if !self.config.enabled || !self.config.user_review.enabled {
422            return Err(crate::Error::Generic(
423                "User access review is not enabled".to_string(),
424            ));
425        }
426
427        let now = Utc::now();
428        let review_id = self.generate_review_id(ReviewType::UserAccess, now);
429        let due_date = now + chrono::Duration::days(self.config.user_review.approval_timeout_days as i64);
430        let next_review = self.config.user_review.frequency.next_review_date(now);
431
432        // Analyze users and generate findings
433        let mut findings = ReviewFindings {
434            inactive_users: 0,
435            excessive_permissions: 0,
436            no_recent_access: 0,
437            privileged_without_mfa: 0,
438            unused_tokens: 0,
439            excessive_scopes: 0,
440            expiring_soon: 0,
441            custom: HashMap::new(),
442        };
443
444        let mut review_items = HashMap::new();
445
446        for user in &users {
447            // Check for inactive users
448            if let Some(days) = user.days_inactive {
449                if days > self.config.user_review.inactive_threshold_days {
450                    findings.inactive_users += 1;
451                }
452            }
453
454            // Check for no recent access
455            if user.last_login.is_none() || user.last_login.unwrap() < now - chrono::Duration::days(90) {
456                findings.no_recent_access += 1;
457            }
458
459            // Check for excessive permissions (heuristic: more than 10 permissions)
460            if user.permissions.len() > 10 {
461                findings.excessive_permissions += 1;
462            }
463
464            // Create review item
465            let review_item = UserReviewItem {
466                review_id: review_id.clone(),
467                user_id: user.user_id,
468                access_info: user.clone(),
469                status: "pending".to_string(),
470                manager_id: None, // Would be populated from user's manager relationship
471                approval_deadline: Some(due_date),
472                approved_by: None,
473                approved_at: None,
474                rejection_reason: None,
475            };
476
477            review_items.insert(user.user_id, review_item);
478        }
479
480        let review = AccessReview {
481            review_id: review_id.clone(),
482            review_type: ReviewType::UserAccess,
483            status: ReviewStatus::InProgress,
484            review_date: now,
485            due_date,
486            total_items: users.len() as u32,
487            items_reviewed: 0,
488            findings: findings.clone(),
489            actions_taken: ReviewActions {
490                users_revoked: 0,
491                permissions_reduced: 0,
492                mfa_enforced: 0,
493                tokens_revoked: 0,
494                tokens_rotated: 0,
495                scopes_reduced: 0,
496                custom: HashMap::new(),
497            },
498            pending_approvals: review_items.len() as u32,
499            next_review_date: next_review,
500            metadata: HashMap::new(),
501        };
502
503        self.active_reviews.insert(review_id.clone(), review.clone());
504        self.user_review_items.insert(review_id, review_items);
505
506        Ok(review)
507    }
508
509    /// Approve a user's access in a review
510    pub fn approve_user_access(
511        &mut self,
512        review_id: &str,
513        user_id: Uuid,
514        approved_by: Uuid,
515        justification: Option<String>,
516    ) -> Result<(), crate::Error> {
517        let review = self
518            .active_reviews
519            .get_mut(review_id)
520            .ok_or_else(|| crate::Error::Generic(format!("Review {} not found", review_id)))?;
521
522        let items = self
523            .user_review_items
524            .get_mut(review_id)
525            .ok_or_else(|| crate::Error::Generic(format!("Review items for {} not found", review_id)))?;
526
527        let item = items
528            .get_mut(&user_id)
529            .ok_or_else(|| crate::Error::Generic(format!("User {} not found in review", user_id)))?;
530
531        item.status = "approved".to_string();
532        item.approved_by = Some(approved_by);
533        item.approved_at = Some(Utc::now());
534
535        review.items_reviewed += 1;
536        review.pending_approvals = review.pending_approvals.saturating_sub(1);
537
538        // Add justification to metadata if provided
539        if let Some(just) = justification {
540            review
541                .metadata
542                .insert(format!("justification_{}", user_id), serde_json::json!(just));
543        }
544
545        Ok(())
546    }
547
548    /// Revoke a user's access in a review
549    pub fn revoke_user_access(
550        &mut self,
551        review_id: &str,
552        user_id: Uuid,
553        revoked_by: Uuid,
554        reason: String,
555    ) -> Result<(), crate::Error> {
556        let review = self
557            .active_reviews
558            .get_mut(review_id)
559            .ok_or_else(|| crate::Error::Generic(format!("Review {} not found", review_id)))?;
560
561        let items = self
562            .user_review_items
563            .get_mut(review_id)
564            .ok_or_else(|| crate::Error::Generic(format!("Review items for {} not found", review_id)))?;
565
566        let item = items
567            .get_mut(&user_id)
568            .ok_or_else(|| crate::Error::Generic(format!("User {} not found in review", user_id)))?;
569
570        item.status = "revoked".to_string();
571        item.rejection_reason = Some(reason.clone());
572
573        review.items_reviewed += 1;
574        review.pending_approvals = review.pending_approvals.saturating_sub(1);
575        review.actions_taken.users_revoked += 1;
576
577        // Add revocation reason to metadata
578        review
579            .metadata
580            .insert(format!("revocation_reason_{}", user_id), serde_json::json!(reason));
581
582        Ok(())
583    }
584
585    /// Get a review by ID
586    pub fn get_review(&self, review_id: &str) -> Option<&AccessReview> {
587        self.active_reviews.get(review_id)
588    }
589
590    /// Get all active reviews
591    pub fn get_all_reviews(&self) -> Vec<&AccessReview> {
592        self.active_reviews.values().collect()
593    }
594
595    /// Get review items for a review
596    pub fn get_review_items(&self, review_id: &str) -> Option<&HashMap<Uuid, UserReviewItem>> {
597        self.user_review_items.get(review_id)
598    }
599
600    /// Check for reviews that need auto-revocation
601    ///
602    /// This checks all pending review items and automatically revokes access
603    /// for items that have exceeded their approval deadline.
604    pub fn check_auto_revocation(&mut self) -> Vec<(String, Uuid)> {
605        let now = Utc::now();
606        let mut revoked = Vec::new();
607
608        for (review_id, items) in &mut self.user_review_items {
609            let review = match self.active_reviews.get_mut(review_id) {
610                Some(r) => r,
611                None => continue,
612            };
613
614            if !self.config.user_review.auto_revoke_inactive {
615                continue;
616            }
617
618            for (user_id, item) in items.iter_mut() {
619                if item.status == "pending" {
620                    if let Some(deadline) = item.approval_deadline {
621                        if now > deadline {
622                            // Auto-revoke
623                            item.status = "auto_revoked".to_string();
624                            item.rejection_reason = Some(
625                                "Access automatically revoked due to missing approval within deadline".to_string(),
626                            );
627
628                            review.items_reviewed += 1;
629                            review.pending_approvals = review.pending_approvals.saturating_sub(1);
630                            review.actions_taken.users_revoked += 1;
631
632                            revoked.push((review_id.clone(), *user_id));
633                        }
634                    }
635                }
636            }
637        }
638
639        revoked
640    }
641
642    /// Complete a review
643    pub fn complete_review(&mut self, review_id: &str) -> Result<(), crate::Error> {
644        let review = self
645            .active_reviews
646            .get_mut(review_id)
647            .ok_or_else(|| crate::Error::Generic(format!("Review {} not found", review_id)))?;
648
649        review.status = ReviewStatus::Completed;
650
651        Ok(())
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_review_frequency_duration() {
661        assert_eq!(ReviewFrequency::Monthly.duration(), Duration::days(30));
662        assert_eq!(ReviewFrequency::Quarterly.duration(), Duration::days(90));
663        assert_eq!(ReviewFrequency::Annually.duration(), Duration::days(365));
664    }
665
666    #[test]
667    fn test_generate_review_id() {
668        let config = AccessReviewConfig::default();
669        let engine = AccessReviewEngine::new(config);
670        let date = Utc::now();
671        let id = engine.generate_review_id(ReviewType::UserAccess, date);
672        assert!(id.starts_with("review-"));
673        assert!(id.contains("user"));
674    }
675
676    #[tokio::test]
677    async fn test_start_user_access_review() {
678        let mut config = AccessReviewConfig::default();
679        config.enabled = true;
680        config.user_review.enabled = true;
681
682        let mut engine = AccessReviewEngine::new(config);
683
684        let users = vec![
685            UserAccessInfo {
686                user_id: Uuid::new_v4(),
687                username: "user1".to_string(),
688                email: "user1@example.com".to_string(),
689                roles: vec!["editor".to_string()],
690                permissions: vec!["read".to_string(), "write".to_string()],
691                last_login: Some(Utc::now() - Duration::days(10)),
692                access_granted: Utc::now() - Duration::days(100),
693                days_inactive: Some(10),
694                is_active: true,
695            },
696            UserAccessInfo {
697                user_id: Uuid::new_v4(),
698                username: "user2".to_string(),
699                email: "user2@example.com".to_string(),
700                roles: vec!["admin".to_string()],
701                permissions: (0..15).map(|i| format!("perm{}", i)).collect(),
702                last_login: Some(Utc::now() - Duration::days(120)),
703                access_granted: Utc::now() - Duration::days(200),
704                days_inactive: Some(120),
705                is_active: true,
706            },
707        ];
708
709        let review = engine.start_user_access_review(users).await.unwrap();
710        assert_eq!(review.review_type, ReviewType::UserAccess);
711        assert_eq!(review.total_items, 2);
712        assert!(review.findings.inactive_users > 0);
713        assert!(review.findings.excessive_permissions > 0);
714    }
715
716    #[test]
717    fn test_approve_user_access() {
718        let mut config = AccessReviewConfig::default();
719        config.enabled = true;
720        config.user_review.enabled = true;
721
722        let mut engine = AccessReviewEngine::new(config);
723
724        let user = UserAccessInfo {
725            user_id: Uuid::new_v4(),
726            username: "user1".to_string(),
727            email: "user1@example.com".to_string(),
728            roles: vec!["editor".to_string()],
729            permissions: vec!["read".to_string()],
730            last_login: Some(Utc::now()),
731            access_granted: Utc::now() - Duration::days(10),
732            days_inactive: Some(0),
733            is_active: true,
734        };
735
736        // Start review
737        let review = futures::executor::block_on(engine.start_user_access_review(vec![user.clone()])).unwrap();
738        let review_id = review.review_id.clone();
739
740        // Approve access
741        let approver_id = Uuid::new_v4();
742        engine
743            .approve_user_access(&review_id, user.user_id, approver_id, None)
744            .unwrap();
745
746        let review = engine.get_review(&review_id).unwrap();
747        assert_eq!(review.items_reviewed, 1);
748        assert_eq!(review.pending_approvals, 0);
749    }
750
751    #[test]
752    fn test_revoke_user_access() {
753        let mut config = AccessReviewConfig::default();
754        config.enabled = true;
755        config.user_review.enabled = true;
756
757        let mut engine = AccessReviewEngine::new(config);
758
759        let user = UserAccessInfo {
760            user_id: Uuid::new_v4(),
761            username: "user1".to_string(),
762            email: "user1@example.com".to_string(),
763            roles: vec!["editor".to_string()],
764            permissions: vec!["read".to_string()],
765            last_login: Some(Utc::now()),
766            access_granted: Utc::now() - Duration::days(10),
767            days_inactive: Some(0),
768            is_active: true,
769        };
770
771        // Start review
772        let review = futures::executor::block_on(engine.start_user_access_review(vec![user.clone()])).unwrap();
773        let review_id = review.review_id.clone();
774
775        // Revoke access
776        let revoker_id = Uuid::new_v4();
777        engine
778            .revoke_user_access(&review_id, user.user_id, revoker_id, "No longer needed".to_string())
779            .unwrap();
780
781        let review = engine.get_review(&review_id).unwrap();
782        assert_eq!(review.actions_taken.users_revoked, 1);
783    }
784}