auth_framework/migration/
validators.rs

1//! Migration plan validators
2//!
3//! This module provides validation functionality for migration plans
4//! to ensure they are safe and complete before execution.
5
6use super::{MigrationConfig, MigrationError, MigrationOperation, MigrationPlan, ValidationType};
7use std::collections::{HashMap, HashSet};
8
9/// Validate migration plan for safety and completeness
10pub async fn validate_migration_plan(
11    plan: &MigrationPlan,
12    _config: &MigrationConfig,
13) -> Result<Vec<String>, MigrationError> {
14    let mut warnings = Vec::new();
15
16    // Validate phase dependencies
17    validate_phase_dependencies(plan, &mut warnings)?;
18
19    // Validate role mappings
20    validate_role_mappings(plan, &mut warnings)?;
21
22    // Validate permission mappings
23    validate_permission_mappings(plan, &mut warnings)?;
24
25    // Validate user migrations
26    validate_user_migrations(plan, &mut warnings)?;
27
28    // Validate backup operations
29    validate_backup_operations(plan, &mut warnings)?;
30
31    // Validate rollback plan
32    validate_rollback_plan(plan, &mut warnings)?;
33
34    // Validate validation steps
35    validate_validation_steps(plan, &mut warnings)?;
36
37    // Check for common migration pitfalls
38    check_migration_pitfalls(plan, &mut warnings)?;
39
40    Ok(warnings)
41}
42
43/// Validate phase dependencies are correct and achievable
44fn validate_phase_dependencies(
45    plan: &MigrationPlan,
46    warnings: &mut Vec<String>,
47) -> Result<(), MigrationError> {
48    let mut phase_ids: HashSet<String> = HashSet::new();
49    let mut phase_order_map: HashMap<String, u32> = HashMap::new();
50
51    // Collect phase IDs and orders
52    for phase in &plan.phases {
53        if phase_ids.contains(&phase.id) {
54            return Err(MigrationError::ValidationError(format!(
55                "Duplicate phase ID found: {}",
56                phase.id
57            )));
58        }
59        phase_ids.insert(phase.id.clone());
60        phase_order_map.insert(phase.id.clone(), phase.order);
61    }
62
63    // Check for circular dependencies first
64    if has_circular_dependencies(&plan.phases) {
65        return Err(MigrationError::ValidationError(
66            "Circular dependencies detected in migration phases".to_string(),
67        ));
68    }
69
70    // Validate dependencies
71    for phase in &plan.phases {
72        for dependency in &phase.dependencies {
73            if !phase_ids.contains(dependency) {
74                return Err(MigrationError::ValidationError(format!(
75                    "Phase '{}' depends on non-existent phase '{}'",
76                    phase.id, dependency
77                )));
78            }
79
80            // Check dependency ordering
81            if let Some(&dep_order) = phase_order_map.get(dependency)
82                && dep_order >= phase.order
83            {
84                return Err(MigrationError::ValidationError(format!(
85                    "Phase '{}' (order {}) depends on phase '{}' (order {}), but dependency should have lower order",
86                    phase.id, phase.order, dependency, dep_order
87                )));
88            }
89        }
90    }
91
92    // Previous circular dependency check was here - moved up
93
94    // Warn about phases with no dependencies (except the first)
95    let phases_with_deps: HashSet<_> = plan.phases.iter().flat_map(|p| &p.dependencies).collect();
96
97    for phase in &plan.phases {
98        if phase.dependencies.is_empty() && phase.order > 1 {
99            warnings.push(format!(
100                "Phase '{}' has no dependencies but is not the first phase",
101                phase.id
102            ));
103        }
104        if !phases_with_deps.contains(&phase.id) && phase.order < plan.phases.len() as u32 {
105            warnings.push(format!(
106                "Phase '{}' is not a dependency of any other phase",
107                phase.id
108            ));
109        }
110    }
111
112    Ok(())
113}
114
115/// Check for circular dependencies in phases
116fn has_circular_dependencies(phases: &[super::MigrationPhase]) -> bool {
117    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
118
119    for phase in phases {
120        graph.insert(phase.id.clone(), phase.dependencies.clone());
121    }
122
123    for phase_id in graph.keys() {
124        if has_cycle_from_node(phase_id, &graph, &mut HashSet::new(), &mut HashSet::new()) {
125            return true;
126        }
127    }
128
129    false
130}
131
132/// Check for cycles starting from a specific node
133fn has_cycle_from_node(
134    node: &str,
135    graph: &HashMap<String, Vec<String>>,
136    visiting: &mut HashSet<String>,
137    visited: &mut HashSet<String>,
138) -> bool {
139    if visiting.contains(node) {
140        return true; // Cycle detected
141    }
142
143    if visited.contains(node) {
144        return false; // Already processed
145    }
146
147    visiting.insert(node.to_string());
148
149    if let Some(dependencies) = graph.get(node) {
150        for dep in dependencies {
151            if has_cycle_from_node(dep, graph, visiting, visited) {
152                return true;
153            }
154        }
155    }
156
157    visiting.remove(node);
158    visited.insert(node.to_string());
159    false
160}
161
162/// Validate role mappings are complete and consistent
163fn validate_role_mappings(
164    plan: &MigrationPlan,
165    warnings: &mut Vec<String>,
166) -> Result<(), MigrationError> {
167    let legacy_roles: HashSet<_> = plan.source_analysis.roles.iter().map(|r| &r.id).collect();
168
169    let mapped_roles: HashSet<_> = plan.role_mappings.keys().collect();
170
171    // Check for unmapped legacy roles
172    for legacy_role in &legacy_roles {
173        if !mapped_roles.contains(legacy_role) {
174            warnings.push(format!(
175                "Legacy role '{}' is not mapped to a new role",
176                legacy_role
177            ));
178        }
179    }
180
181    // Check for duplicate new role IDs
182    let mut new_role_ids: HashMap<&String, Vec<&String>> = HashMap::new();
183    for (legacy_id, new_id) in &plan.role_mappings {
184        new_role_ids.entry(new_id).or_default().push(legacy_id);
185    }
186
187    for (new_id, legacy_ids) in new_role_ids {
188        if legacy_ids.len() > 1 {
189            warnings.push(format!(
190                "New role '{}' is mapped from multiple legacy roles: {:?}",
191                new_id, legacy_ids
192            ));
193        }
194    }
195
196    // Validate role creation operations
197    let role_creation_ops: HashSet<_> = plan
198        .phases
199        .iter()
200        .flat_map(|p| &p.operations)
201        .filter_map(|op| match op {
202            MigrationOperation::CreateRole { role_id, .. } => Some(role_id),
203            _ => None,
204        })
205        .collect();
206
207    for new_role_id in plan.role_mappings.values() {
208        if !role_creation_ops.contains(new_role_id) {
209            warnings.push(format!(
210                "Role '{}' is mapped but not created in any phase",
211                new_role_id
212            ));
213        }
214    }
215
216    Ok(())
217}
218
219/// Validate permission mappings
220fn validate_permission_mappings(
221    plan: &MigrationPlan,
222    warnings: &mut Vec<String>,
223) -> Result<(), MigrationError> {
224    let legacy_permissions: HashSet<_> = plan
225        .source_analysis
226        .permissions
227        .iter()
228        .map(|p| &p.id)
229        .collect();
230
231    let mapped_permissions: HashSet<_> = plan.permission_mappings.keys().collect();
232
233    // Check for unmapped legacy permissions
234    for legacy_permission in &legacy_permissions {
235        if !mapped_permissions.contains(legacy_permission) {
236            warnings.push(format!(
237                "Legacy permission '{}' is not mapped",
238                legacy_permission
239            ));
240        }
241    }
242
243    // Validate permission creation operations
244    let permission_creation_ops: HashSet<_> = plan
245        .phases
246        .iter()
247        .flat_map(|p| &p.operations)
248        .filter_map(|op| match op {
249            MigrationOperation::CreatePermission { permission_id, .. } => Some(permission_id),
250            _ => None,
251        })
252        .collect();
253
254    for new_permission_id in plan.permission_mappings.values() {
255        if !permission_creation_ops.contains(new_permission_id) {
256            warnings.push(format!(
257                "Permission '{}' is mapped but not created in any phase",
258                new_permission_id
259            ));
260        }
261    }
262
263    Ok(())
264}
265
266/// Validate user migrations
267fn validate_user_migrations(
268    plan: &MigrationPlan,
269    warnings: &mut Vec<String>,
270) -> Result<(), MigrationError> {
271    let legacy_users: HashSet<_> = plan
272        .source_analysis
273        .user_assignments
274        .iter()
275        .map(|u| &u.user_id)
276        .collect();
277
278    let migrated_users: HashSet<_> = plan.user_migrations.iter().map(|m| &m.user_id).collect();
279
280    // Check for unmigrated users
281    for legacy_user in &legacy_users {
282        if !migrated_users.contains(legacy_user) {
283            warnings.push(format!(
284                "User '{}' has legacy assignments but no migration plan",
285                legacy_user
286            ));
287        }
288    }
289
290    // Validate user role assignment operations
291    let user_assignment_ops: HashSet<_> = plan
292        .phases
293        .iter()
294        .flat_map(|p| &p.operations)
295        .filter_map(|op| match op {
296            MigrationOperation::AssignUserRole { user_id, .. } => Some(user_id),
297            _ => None,
298        })
299        .collect();
300
301    for user_migration in &plan.user_migrations {
302        if user_migration.user_id != "TEMPLATE"
303            && !user_assignment_ops.contains(&user_migration.user_id)
304        {
305            warnings.push(format!(
306                "User '{}' has migration plan but no assignment operations",
307                user_migration.user_id
308            ));
309        }
310    }
311
312    // Check for role mappings consistency
313    for user_migration in &plan.user_migrations {
314        for legacy_role in &user_migration.legacy_roles {
315            if !plan.role_mappings.contains_key(legacy_role)
316                && legacy_role != "REQUIRES_MANUAL_MAPPING"
317            {
318                warnings.push(format!(
319                    "User migration for '{}' references unmapped legacy role '{}'",
320                    user_migration.user_id, legacy_role
321                ));
322            }
323        }
324    }
325
326    Ok(())
327}
328
329/// Validate backup operations
330fn validate_backup_operations(
331    plan: &MigrationPlan,
332    warnings: &mut Vec<String>,
333) -> Result<(), MigrationError> {
334    let backup_ops: Vec<_> = plan
335        .phases
336        .iter()
337        .flat_map(|p| &p.operations)
338        .filter_map(|op| match op {
339            MigrationOperation::Backup {
340                backup_location,
341                backup_type,
342            } => Some((backup_location, backup_type)),
343            _ => None,
344        })
345        .collect();
346
347    if backup_ops.is_empty() {
348        return Err(MigrationError::ValidationError(
349            "No backup operations found in migration plan".to_string(),
350        ));
351    }
352
353    // Check if first phase includes backup
354    if let Some(first_phase) = plan.phases.first() {
355        let has_backup = first_phase
356            .operations
357            .iter()
358            .any(|op| matches!(op, MigrationOperation::Backup { .. }));
359
360        if !has_backup {
361            warnings.push("First phase does not include a backup operation".to_string());
362        }
363    }
364
365    // Validate backup locations are different
366    let mut backup_locations = HashSet::new();
367    for (location, _) in backup_ops {
368        if !backup_locations.insert(location) {
369            warnings.push(format!("Duplicate backup location: {:?}", location));
370        }
371    }
372
373    Ok(())
374}
375
376/// Validate rollback plan
377fn validate_rollback_plan(
378    plan: &MigrationPlan,
379    warnings: &mut Vec<String>,
380) -> Result<(), MigrationError> {
381    if plan.rollback_plan.phases.is_empty() {
382        return Err(MigrationError::ValidationError(
383            "Rollback plan has no phases".to_string(),
384        ));
385    }
386
387    if plan.rollback_plan.backup_locations.is_empty() {
388        warnings.push("Rollback plan has no backup locations specified".to_string());
389    }
390
391    // Check if rollback phases are ordered
392    let mut last_order = 0;
393    for phase in &plan.rollback_plan.phases {
394        if phase.order <= last_order && last_order > 0 {
395            warnings.push(format!("Rollback phase '{}' has incorrect order", phase.id));
396        }
397        last_order = phase.order;
398    }
399
400    // Validate RTO is reasonable
401    if plan.rollback_plan.recovery_time_objective > chrono::Duration::hours(24) {
402        warnings.push("Recovery Time Objective exceeds 24 hours".to_string());
403    }
404
405    Ok(())
406}
407
408/// Validate validation steps
409fn validate_validation_steps(
410    plan: &MigrationPlan,
411    warnings: &mut Vec<String>,
412) -> Result<(), MigrationError> {
413    // Check for required validation types
414    let required_validations = vec![
415        ValidationType::HierarchyIntegrity,
416        ValidationType::PermissionConsistency,
417        ValidationType::UserAssignmentValidity,
418    ];
419
420    let post_validation_types: HashSet<_> = plan
421        .post_validation_steps
422        .iter()
423        .map(|step| &step.validation_type)
424        .collect();
425
426    for required_validation in required_validations {
427        if !post_validation_types.contains(&required_validation) {
428            warnings.push(format!(
429                "Missing required post-migration validation: {:?}",
430                required_validation
431            ));
432        }
433    }
434
435    // Check for duplicate validation IDs
436    let mut validation_ids = HashSet::new();
437    for step in &plan.pre_validation_steps {
438        if !validation_ids.insert(&step.id) {
439            return Err(MigrationError::ValidationError(format!(
440                "Duplicate pre-validation step ID: {}",
441                step.id
442            )));
443        }
444    }
445
446    validation_ids.clear();
447    for step in &plan.post_validation_steps {
448        if !validation_ids.insert(&step.id) {
449            return Err(MigrationError::ValidationError(format!(
450                "Duplicate post-validation step ID: {}",
451                step.id
452            )));
453        }
454    }
455
456    Ok(())
457}
458
459/// Check for common migration pitfalls
460fn check_migration_pitfalls(
461    plan: &MigrationPlan,
462    warnings: &mut Vec<String>,
463) -> Result<(), MigrationError> {
464    // Check for privilege escalation risks
465    check_privilege_escalation_risks(plan, warnings);
466
467    // Check for orphaned permissions
468    check_orphaned_permissions_handling(plan, warnings);
469
470    // Check for circular dependencies in source
471    check_circular_dependency_handling(plan, warnings);
472
473    // Check duration estimates
474    check_duration_estimates(plan, warnings);
475
476    // Check for missing error handling
477    check_error_handling(plan, warnings);
478
479    Ok(())
480}
481
482/// Check for potential privilege escalation during migration
483fn check_privilege_escalation_risks(plan: &MigrationPlan, warnings: &mut Vec<String>) {
484    // Look for users getting more permissions than they had before
485    for user_migration in &plan.user_migrations {
486        if user_migration.legacy_permissions.len() < user_migration.new_roles.len() * 5 {
487            // Rough heuristic: if new roles significantly outnumber legacy permissions, investigate
488            warnings.push(format!(
489                "User '{}' may have privilege escalation - verify role assignments",
490                user_migration.user_id
491            ));
492        }
493    }
494
495    // Check if privilege escalation validation is included
496    let has_privilege_check = plan.post_validation_steps.iter().any(|step| {
497        matches!(
498            step.validation_type,
499            ValidationType::PrivilegeEscalationCheck
500        )
501    });
502
503    if !has_privilege_check {
504        warnings
505            .push("No privilege escalation check found in post-migration validation".to_string());
506    }
507}
508
509/// Check how orphaned permissions are handled
510fn check_orphaned_permissions_handling(plan: &MigrationPlan, warnings: &mut Vec<String>) {
511    if !plan.source_analysis.orphaned_permissions.is_empty() {
512        let orphaned_handled = plan.phases.iter().any(|phase| {
513            phase.operations.iter().any(|op| match op {
514                MigrationOperation::CreatePermission { permission_id, .. } => plan
515                    .source_analysis
516                    .orphaned_permissions
517                    .contains(permission_id),
518                _ => false,
519            })
520        });
521
522        if !orphaned_handled {
523            warnings.push(format!("Found {} orphaned permissions in source system, but no handling strategy in migration plan",
524                                plan.source_analysis.orphaned_permissions.len()));
525        }
526    }
527}
528
529/// Check how circular dependencies are handled
530fn check_circular_dependency_handling(plan: &MigrationPlan, warnings: &mut Vec<String>) {
531    if !plan.source_analysis.circular_dependencies.is_empty() {
532        warnings.push(format!(
533            "Source system has {} circular dependencies - ensure migration plan addresses these",
534            plan.source_analysis.circular_dependencies.len()
535        ));
536    }
537}
538
539/// Check if duration estimates are reasonable
540fn check_duration_estimates(plan: &MigrationPlan, warnings: &mut Vec<String>) {
541    let total_operations = plan
542        .phases
543        .iter()
544        .map(|phase| phase.operations.len())
545        .sum::<usize>();
546
547    let avg_time_per_operation =
548        plan.estimated_duration.num_minutes() as f64 / total_operations as f64;
549
550    if avg_time_per_operation < 1.0 {
551        warnings.push(
552            "Duration estimates may be too optimistic - less than 1 minute per operation"
553                .to_string(),
554        );
555    } else if avg_time_per_operation > 30.0 {
556        warnings.push(
557            "Duration estimates may be too conservative - more than 30 minutes per operation"
558                .to_string(),
559        );
560    }
561
562    // Check for unrealistic downtime
563    if let Some(downtime) = plan.downtime_required
564        && downtime > chrono::Duration::hours(8)
565    {
566        warnings.push("Required downtime exceeds 8 hours - consider gradual migration".to_string());
567    }
568}
569
570/// Check for error handling provisions
571fn check_error_handling(plan: &MigrationPlan, warnings: &mut Vec<String>) {
572    let has_validation_ops = plan.phases.iter().any(|phase| {
573        phase
574            .operations
575            .iter()
576            .any(|op| matches!(op, MigrationOperation::ValidateIntegrity { .. }))
577    });
578
579    if !has_validation_ops {
580        warnings.push("No validation operations found in migration phases".to_string());
581    }
582
583    // Check if each phase has rollback operations
584    for phase in &plan.phases {
585        if phase.rollback_operations.is_empty() {
586            warnings.push(format!(
587                "Phase '{}' has no rollback operations defined",
588                phase.id
589            ));
590        }
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::migration::{
598        BackupType, LegacySystemAnalysis, LegacySystemType, MigrationOperation, MigrationPhase,
599        MigrationStrategy,
600    };
601
602    fn create_test_plan() -> MigrationPlan {
603        MigrationPlan {
604            id: "test_plan".to_string(),
605            source_analysis: LegacySystemAnalysis {
606                system_type: LegacySystemType::BasicRbac,
607                role_count: 1,
608                permission_count: 1,
609                user_assignment_count: 1,
610                roles: vec![],
611                permissions: vec![],
612                user_assignments: vec![],
613                hierarchy_depth: 0,
614                duplicates_found: false,
615                orphaned_permissions: vec![],
616                circular_dependencies: vec![],
617                custom_attributes: std::collections::HashSet::new(),
618                complexity_score: 3,
619                recommended_strategy: MigrationStrategy::DirectMapping,
620            },
621            strategy: MigrationStrategy::DirectMapping,
622            phases: vec![
623                MigrationPhase {
624                    id: "phase1".to_string(),
625                    name: "Phase 1".to_string(),
626                    description: "First phase".to_string(),
627                    order: 1,
628                    operations: vec![MigrationOperation::Backup {
629                        backup_location: std::path::PathBuf::from("./backup"),
630                        backup_type: BackupType::Full,
631                    }],
632                    dependencies: vec![],
633                    estimated_duration: chrono::Duration::minutes(30),
634                    rollback_operations: vec![],
635                },
636                MigrationPhase {
637                    id: "phase2".to_string(),
638                    name: "Phase 2".to_string(),
639                    description: "Second phase".to_string(),
640                    order: 2,
641                    operations: vec![],
642                    dependencies: vec!["phase1".to_string()],
643                    estimated_duration: chrono::Duration::minutes(30),
644                    rollback_operations: vec![],
645                },
646            ],
647            role_mappings: HashMap::new(),
648            permission_mappings: HashMap::new(),
649            user_migrations: vec![],
650            pre_validation_steps: vec![],
651            post_validation_steps: vec![],
652            rollback_plan: super::super::RollbackPlan {
653                phases: vec![
654                    super::super::RollbackPhase {
655                        id: "rollback_phase1".to_string(),
656                        name: "Rollback Phase 1".to_string(),
657                        order: 1,
658                        operations: vec![],
659                    },
660                    super::super::RollbackPhase {
661                        id: "rollback_phase2".to_string(),
662                        name: "Rollback Phase 2".to_string(),
663                        order: 2,
664                        operations: vec![],
665                    },
666                ],
667                backup_locations: vec![std::path::PathBuf::from("./backup")],
668                recovery_time_objective: chrono::Duration::hours(2),
669                manual_steps: vec![],
670            },
671            estimated_duration: chrono::Duration::hours(1),
672            risk_level: super::super::RiskLevel::Low,
673            downtime_required: None,
674        }
675    }
676
677    #[tokio::test]
678    async fn test_validate_phase_dependencies() {
679        let plan = create_test_plan();
680        let config = MigrationConfig::default();
681
682        let warnings = validate_migration_plan(&plan, &config).await.unwrap();
683
684        // Should have some warnings but no errors
685        assert!(!warnings.is_empty());
686    }
687
688    #[tokio::test]
689    async fn test_circular_dependency_detection() {
690        let mut plan = create_test_plan();
691
692        // Create circular dependency without violating order constraints
693        // Add a third phase with order 0 (before phase1)
694        plan.phases.push(super::super::MigrationPhase {
695            id: "phase0".to_string(),
696            name: "Phase 0".to_string(),
697            description: "Zero phase".to_string(),
698            order: 0,
699            operations: vec![],
700            dependencies: vec!["phase2".to_string()], // phase0 depends on phase2 (creates cycle: phase0 -> phase2 -> phase1 -> phase0)
701            estimated_duration: chrono::Duration::minutes(30),
702            rollback_operations: vec![],
703        });
704
705        // Now make phase1 depend on phase0 to create a cycle
706        plan.phases[0].dependencies = vec!["phase0".to_string()];
707
708        let config = MigrationConfig::default();
709        let result = validate_migration_plan(&plan, &config).await;
710
711        assert!(result.is_err());
712        assert!(
713            result
714                .unwrap_err()
715                .to_string()
716                .contains("Circular dependencies")
717        );
718    }
719
720    #[test]
721    fn test_has_circular_dependencies() {
722        let phases = vec![
723            super::super::MigrationPhase {
724                id: "a".to_string(),
725                name: "A".to_string(),
726                description: "".to_string(),
727                order: 1,
728                operations: vec![],
729                dependencies: vec!["b".to_string()],
730                estimated_duration: chrono::Duration::minutes(1),
731                rollback_operations: vec![],
732            },
733            super::super::MigrationPhase {
734                id: "b".to_string(),
735                name: "B".to_string(),
736                description: "".to_string(),
737                order: 2,
738                operations: vec![],
739                dependencies: vec!["a".to_string()],
740                estimated_duration: chrono::Duration::minutes(1),
741                rollback_operations: vec![],
742            },
743        ];
744
745        assert!(has_circular_dependencies(&phases));
746    }
747}
748
749