1use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
8use datasynth_core::models::{AccessLog, ChangeManagementRecord};
9use datasynth_core::utils::seeded_rng;
10use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13
14const ACCESS_ACTIONS: &[(&str, f64)] = &[
20 ("login", 0.60),
21 ("logout", 0.85),
22 ("failed_login", 0.90),
23 ("privilege_change", 0.95),
24 ("data_export", 1.00),
25];
26
27const CHANGE_TYPES: &[(&str, f64)] = &[
29 ("config_change", 0.30),
30 ("code_deployment", 0.55),
31 ("patch", 0.75),
32 ("access_change", 0.90),
33 ("emergency_fix", 1.00),
34];
35
36const CONFIG_CHANGE_DESCRIPTIONS: &[&str] = &[
38 "Updated firewall rules for DMZ",
39 "Modified database connection pool settings",
40 "Changed application timeout parameters",
41 "Updated email relay configuration",
42 "Modified backup retention policy",
43 "Adjusted logging verbosity levels",
44 "Changed SSL/TLS certificate configuration",
45 "Updated LDAP authentication settings",
46];
47
48const CODE_DEPLOYMENT_DESCRIPTIONS: &[&str] = &[
49 "Deployed financial reporting module v2.3",
50 "Released hotfix for invoice processing",
51 "Deployed updated reconciliation engine",
52 "Released new user interface components",
53 "Deployed API gateway update",
54 "Released batch processing optimization",
55 "Deployed security patch for web application",
56 "Released data migration scripts",
57];
58
59const PATCH_DESCRIPTIONS: &[&str] = &[
60 "Applied OS security patch KB-2024-001",
61 "Updated database server to latest patch level",
62 "Applied middleware security update",
63 "Patched web server vulnerability CVE-2024-1234",
64 "Applied ERP kernel update",
65 "Updated antivirus definitions",
66 "Applied network firmware update",
67 "Patched authentication module vulnerability",
68];
69
70const ACCESS_CHANGE_DESCRIPTIONS: &[&str] = &[
71 "Granted read access to financial reports",
72 "Revoked terminated employee access",
73 "Modified role assignment for department transfer",
74 "Added privileged access for system maintenance",
75 "Updated service account permissions",
76 "Removed legacy admin access rights",
77 "Granted vendor portal access",
78 "Modified segregation of duties profile",
79];
80
81const EMERGENCY_FIX_DESCRIPTIONS: &[&str] = &[
82 "Emergency fix for production outage",
83 "Critical security vulnerability remediation",
84 "Emergency database recovery procedure",
85 "Urgent fix for data corruption issue",
86 "Emergency patch for authentication bypass",
87 "Critical fix for payment processing failure",
88 "Emergency rollback of failed deployment",
89 "Urgent fix for regulatory reporting deadline",
90];
91
92const TEST_EVIDENCE_TEMPLATES: &[&str] = &[
93 "UAT sign-off document ref: UAT-2024-{:04}",
94 "Regression test suite passed: TS-{:04}",
95 "Integration test report: ITR-{:04}",
96 "Performance test results: PTR-{:04}",
97 "Security scan report: SEC-{:04}",
98 "User acceptance testing completed: UAT-{:04}",
99];
100
101pub struct ItControlsGenerator {
107 rng: ChaCha8Rng,
108 uuid_factory: DeterministicUuidFactory,
109}
110
111impl ItControlsGenerator {
112 pub fn new(seed: u64) -> Self {
114 Self {
115 rng: seeded_rng(seed, 0),
116 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ItControls),
117 }
118 }
119
120 pub fn generate_access_logs(
130 &mut self,
131 employee_ids: &[(String, String)], systems: &[String],
133 start_date: NaiveDate,
134 period_months: u32,
135 ) -> Vec<AccessLog> {
136 if employee_ids.is_empty() || systems.is_empty() {
137 return Vec::new();
138 }
139
140 let mut logs = Vec::new();
141
142 for month_offset in 0..period_months {
143 let year = start_date.year() + (start_date.month0() + month_offset) as i32 / 12;
144 let month = (start_date.month0() + month_offset) % 12 + 1;
145 let days_in_month = days_in_month(year, month);
146
147 for (user_id, user_name) in employee_ids {
148 let log_count = self.rng.random_range(10u32..=30);
149 let primary_system = &systems[self.rng.random_range(0..systems.len())];
151 let ip_address = self.generate_ip();
152
153 let has_failed_cluster = self.rng.random_bool(0.08);
155 let cluster_day = if has_failed_cluster {
156 self.rng.random_range(1..=days_in_month)
157 } else {
158 1 };
160
161 for i in 0..log_count {
162 let day = self.rng.random_range(1..=days_in_month);
163 let (hour, minute, second) = self.generate_time();
164
165 let Some(date) = NaiveDate::from_ymd_opt(year, month, day) else {
166 continue;
167 };
168 let Some(time) = NaiveTime::from_hms_opt(hour, minute, second) else {
169 continue;
170 };
171 let timestamp = NaiveDateTime::new(date, time);
172
173 let (action, success) = self.pick_action();
174 let system = if self.rng.random_bool(0.7) {
175 primary_system.clone()
176 } else {
177 systems[self.rng.random_range(0..systems.len())].clone()
178 };
179
180 let session_duration = if action == "logout" {
181 Some(self.rng.random_range(5u32..=480))
182 } else {
183 None
184 };
185
186 logs.push(AccessLog {
187 log_id: self.uuid_factory.next(),
188 timestamp,
189 user_id: user_id.clone(),
190 user_name: user_name.clone(),
191 system,
192 action,
193 success,
194 ip_address: ip_address.clone(),
195 session_duration_minutes: session_duration,
196 });
197
198 if has_failed_cluster && i == 0 {
200 let cluster_size = self.rng.random_range(3u32..=5);
201 let Some(cluster_date) = NaiveDate::from_ymd_opt(year, month, cluster_day)
202 else {
203 continue;
204 };
205
206 for j in 0..cluster_size {
207 let cluster_minute = self.rng.random_range(0u32..=2);
208 let cluster_second = self.rng.random_range(0u32..=59);
209 let cluster_hour = self.rng.random_range(1u32..=5); let Some(ct) = NaiveTime::from_hms_opt(
211 cluster_hour,
212 cluster_minute + j,
213 cluster_second,
214 ) else {
215 continue;
216 };
217
218 logs.push(AccessLog {
219 log_id: self.uuid_factory.next(),
220 timestamp: NaiveDateTime::new(cluster_date, ct),
221 user_id: user_id.clone(),
222 user_name: user_name.clone(),
223 system: primary_system.clone(),
224 action: "failed_login".to_string(),
225 success: false,
226 ip_address: self.generate_ip(), session_duration_minutes: None,
228 });
229 }
230 }
231 }
232 }
233 }
234
235 logs.sort_by_key(|l| l.timestamp);
237 logs
238 }
239
240 pub fn generate_change_records(
250 &mut self,
251 employee_ids: &[(String, String)],
252 systems: &[String],
253 start_date: NaiveDate,
254 period_months: u32,
255 ) -> Vec<ChangeManagementRecord> {
256 if employee_ids.is_empty() || systems.is_empty() {
257 return Vec::new();
258 }
259
260 let mut records = Vec::new();
261
262 for month_offset in 0..period_months {
263 let year = start_date.year() + (start_date.month0() + month_offset) as i32 / 12;
264 let month = (start_date.month0() + month_offset) % 12 + 1;
265 let days_in_month = days_in_month(year, month);
266
267 let changes_this_month = self.rng.random_range(5u32..=15);
268
269 for _ in 0..changes_this_month {
270 let change_type = self.pick_change_type();
271 let system = &systems[self.rng.random_range(0..systems.len())];
272 let description = self.pick_description(&change_type);
273
274 let requester_idx = self.rng.random_range(0..employee_ids.len());
275 let requested_by = employee_ids[requester_idx].1.clone();
276
277 let implementer_idx = if employee_ids.len() > 1 {
279 let mut idx = self.rng.random_range(0..employee_ids.len());
280 if idx == requester_idx {
281 idx = (idx + 1) % employee_ids.len();
282 }
283 idx
284 } else {
285 0
286 };
287 let implemented_by = employee_ids[implementer_idx].1.clone();
288
289 let is_emergency = change_type == "emergency_fix";
291 let has_approval = if is_emergency {
292 self.rng.random_bool(0.30)
293 } else {
294 self.rng.random_bool(0.95)
295 };
296
297 let approved_by = if has_approval {
298 let mut approver_idx = self.rng.random_range(0..employee_ids.len());
300 if employee_ids.len() > 2 {
301 while approver_idx == requester_idx || approver_idx == implementer_idx {
302 approver_idx = self.rng.random_range(0..employee_ids.len());
303 }
304 }
305 Some(employee_ids[approver_idx].1.clone())
306 } else {
307 None
308 };
309
310 let tested = if is_emergency {
312 self.rng.random_bool(0.20)
313 } else {
314 self.rng.random_bool(0.90)
315 };
316
317 let test_evidence = if tested {
318 let evidence_num = self.rng.random_range(1u32..=9999);
319 let template = TEST_EVIDENCE_TEMPLATES
320 [self.rng.random_range(0..TEST_EVIDENCE_TEMPLATES.len())];
321 Some(template.replace("{:04}", &format!("{:04}", evidence_num)))
322 } else {
323 None
324 };
325
326 let rollback_plan = if is_emergency {
328 self.rng.random_bool(0.50)
329 } else {
330 self.rng.random_bool(0.98)
331 };
332
333 let request_day = self.rng.random_range(1..=days_in_month);
335 let request_hour = self.rng.random_range(8u32..=17);
336 let request_minute = self.rng.random_range(0u32..=59);
337 let Some(request_date_d) = NaiveDate::from_ymd_opt(year, month, request_day) else {
338 continue;
339 };
340 let Some(request_time) = NaiveTime::from_hms_opt(request_hour, request_minute, 0)
341 else {
342 continue;
343 };
344 let request_date = NaiveDateTime::new(request_date_d, request_time);
345
346 let impl_lag_days = if is_emergency {
349 self.rng.random_range(0i64..=1)
350 } else {
351 self.rng.random_range(1i64..=14)
352 };
353 let impl_date_d = request_date_d + chrono::Duration::days(impl_lag_days);
354 let impl_hour = self.rng.random_range(8u32..=22);
355 let impl_minute = self.rng.random_range(0u32..=59);
356 let Some(impl_time) = NaiveTime::from_hms_opt(impl_hour, impl_minute, 0) else {
357 continue;
358 };
359 let implementation_date = NaiveDateTime::new(impl_date_d, impl_time);
360
361 records.push(ChangeManagementRecord {
362 change_id: self.uuid_factory.next(),
363 system: system.clone(),
364 change_type,
365 description,
366 requested_by,
367 approved_by,
368 implemented_by,
369 request_date,
370 implementation_date,
371 tested,
372 test_evidence,
373 rollback_plan,
374 });
375 }
376 }
377
378 records.sort_by_key(|r| r.request_date);
380 records
381 }
382
383 fn pick_action(&mut self) -> (String, bool) {
389 let r: f64 = self.rng.random_range(0.0..1.0);
390 for &(action, threshold) in ACCESS_ACTIONS {
391 if r < threshold {
392 let success = action != "failed_login";
393 return (action.to_string(), success);
394 }
395 }
396 ("login".to_string(), true)
397 }
398
399 fn pick_change_type(&mut self) -> String {
401 let r: f64 = self.rng.random_range(0.0..1.0);
402 for &(ct, threshold) in CHANGE_TYPES {
403 if r < threshold {
404 return ct.to_string();
405 }
406 }
407 "config_change".to_string()
408 }
409
410 fn pick_description(&mut self, change_type: &str) -> String {
412 let pool = match change_type {
413 "config_change" => CONFIG_CHANGE_DESCRIPTIONS,
414 "code_deployment" => CODE_DEPLOYMENT_DESCRIPTIONS,
415 "patch" => PATCH_DESCRIPTIONS,
416 "access_change" => ACCESS_CHANGE_DESCRIPTIONS,
417 "emergency_fix" => EMERGENCY_FIX_DESCRIPTIONS,
418 _ => CONFIG_CHANGE_DESCRIPTIONS,
419 };
420 pool.choose(&mut self.rng)
421 .map(|s| s.to_string())
422 .unwrap_or_else(|| "System change".to_string())
423 }
424
425 fn generate_time(&mut self) -> (u32, u32, u32) {
427 let is_business_hours = self.rng.random_bool(0.80);
428 let hour = if is_business_hours {
429 self.rng.random_range(8u32..=17)
430 } else {
431 if self.rng.random_bool(0.5) {
433 self.rng.random_range(0u32..=7)
434 } else {
435 self.rng.random_range(18u32..=23)
436 }
437 };
438 let minute = self.rng.random_range(0u32..=59);
439 let second = self.rng.random_range(0u32..=59);
440 (hour, minute, second)
441 }
442
443 fn generate_ip(&mut self) -> String {
445 format!(
446 "10.{}.{}.{}",
447 self.rng.random_range(0u8..=255),
448 self.rng.random_range(0u8..=255),
449 self.rng.random_range(1u8..=254),
450 )
451 }
452}
453
454fn days_in_month(year: i32, month: u32) -> u32 {
456 let (next_year, next_month) = if month == 12 {
458 (year + 1, 1)
459 } else {
460 (year, month + 1)
461 };
462 NaiveDate::from_ymd_opt(next_year, next_month, 1)
463 .and_then(|d| d.pred_opt())
464 .map(|d| d.day())
465 .unwrap_or(28)
466}
467
468#[cfg(test)]
473mod tests {
474 use super::*;
475 use chrono::Timelike;
476
477 fn sample_employees() -> Vec<(String, String)> {
478 (1..=10)
479 .map(|i| (format!("EMP-{:04}", i), format!("Employee {}", i)))
480 .collect()
481 }
482
483 fn sample_systems() -> Vec<String> {
484 vec![
485 "SAP-FI".to_string(),
486 "Active Directory".to_string(),
487 "Oracle-HR".to_string(),
488 "ServiceNow".to_string(),
489 ]
490 }
491
492 #[test]
493 fn test_access_logs_generated() {
494 let mut gen = ItControlsGenerator::new(42);
495 let logs = gen.generate_access_logs(
496 &sample_employees(),
497 &sample_systems(),
498 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
499 3,
500 );
501 assert!(!logs.is_empty(), "should produce access logs");
502 for log in &logs {
503 assert!(!log.user_id.is_empty());
504 assert!(!log.user_name.is_empty());
505 assert!(!log.system.is_empty());
506 assert!(!log.action.is_empty());
507 assert!(!log.ip_address.is_empty());
508 assert!(log.ip_address.starts_with("10."));
509 }
510 }
511
512 #[test]
513 fn test_access_log_business_hours() {
514 let mut gen = ItControlsGenerator::new(42);
515 let logs = gen.generate_access_logs(
516 &sample_employees(),
517 &sample_systems(),
518 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
519 6,
520 );
521 let total = logs.len() as f64;
522 let business_hours_count = logs
523 .iter()
524 .filter(|l| {
525 let hour = l.timestamp.time().hour();
526 (8..=17).contains(&hour)
527 })
528 .count() as f64;
529 let ratio = business_hours_count / total;
530 assert!(
531 ratio > 0.70,
532 "expected >70% business hours, got {:.1}%",
533 ratio * 100.0
534 );
535 }
536
537 #[test]
538 fn test_failed_login_rate() {
539 let mut gen = ItControlsGenerator::new(42);
540 let logs = gen.generate_access_logs(
541 &sample_employees(),
542 &sample_systems(),
543 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
544 6,
545 );
546 let total = logs.len() as f64;
547 let failed = logs.iter().filter(|l| l.action == "failed_login").count() as f64;
548 let rate = failed / total;
549 assert!(
550 (0.02..=0.15).contains(&rate),
551 "expected 2-15% failed login rate, got {:.1}%",
552 rate * 100.0
553 );
554 }
555
556 #[test]
557 fn test_access_log_references_employees() {
558 let employees = sample_employees();
559 let employee_ids: std::collections::HashSet<&str> =
560 employees.iter().map(|(id, _)| id.as_str()).collect();
561
562 let mut gen = ItControlsGenerator::new(42);
563 let logs = gen.generate_access_logs(
564 &employees,
565 &sample_systems(),
566 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
567 3,
568 );
569
570 for log in &logs {
571 assert!(
572 employee_ids.contains(log.user_id.as_str()),
573 "user_id {} should come from employee input",
574 log.user_id
575 );
576 }
577 }
578
579 #[test]
580 fn test_change_records_generated() {
581 let mut gen = ItControlsGenerator::new(42);
582 let records = gen.generate_change_records(
583 &sample_employees(),
584 &sample_systems(),
585 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
586 3,
587 );
588 assert!(!records.is_empty(), "should produce change records");
589 for r in &records {
590 assert!(!r.system.is_empty());
591 assert!(!r.change_type.is_empty());
592 assert!(!r.description.is_empty());
593 assert!(!r.requested_by.is_empty());
594 assert!(!r.implemented_by.is_empty());
595 }
596 }
597
598 #[test]
599 fn test_change_approval_rate() {
600 let mut gen = ItControlsGenerator::new(42);
601 let records = gen.generate_change_records(
602 &sample_employees(),
603 &sample_systems(),
604 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
605 12,
606 );
607 let total = records.len() as f64;
608 let approved = records.iter().filter(|r| r.approved_by.is_some()).count() as f64;
609 let rate = approved / total;
610 assert!(
612 rate > 0.75 && rate < 0.99,
613 "expected ~85-95% approval rate, got {:.1}%",
614 rate * 100.0
615 );
616 }
617
618 #[test]
619 fn test_emergency_fixes_unapproved() {
620 let mut gen = ItControlsGenerator::new(42);
621 let records = gen.generate_change_records(
622 &sample_employees(),
623 &sample_systems(),
624 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
625 24,
626 );
627
628 let emergency: Vec<_> = records
629 .iter()
630 .filter(|r| r.change_type == "emergency_fix")
631 .collect();
632 let non_emergency: Vec<_> = records
633 .iter()
634 .filter(|r| r.change_type != "emergency_fix")
635 .collect();
636
637 if !emergency.is_empty() && !non_emergency.is_empty() {
638 let emergency_approval_rate =
639 emergency.iter().filter(|r| r.approved_by.is_some()).count() as f64
640 / emergency.len() as f64;
641 let non_emergency_approval_rate = non_emergency
642 .iter()
643 .filter(|r| r.approved_by.is_some())
644 .count() as f64
645 / non_emergency.len() as f64;
646
647 assert!(
648 emergency_approval_rate < non_emergency_approval_rate,
649 "emergency fixes ({:.0}%) should have lower approval rate than normal changes ({:.0}%)",
650 emergency_approval_rate * 100.0,
651 non_emergency_approval_rate * 100.0
652 );
653 }
654 }
655
656 #[test]
657 fn test_change_dates_ordered() {
658 let mut gen = ItControlsGenerator::new(42);
659 let records = gen.generate_change_records(
660 &sample_employees(),
661 &sample_systems(),
662 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
663 6,
664 );
665
666 for r in &records {
667 assert!(
669 r.implementation_date.date() >= r.request_date.date(),
670 "implementation date {} should be >= request date {} for change {}",
671 r.implementation_date,
672 r.request_date,
673 r.change_id
674 );
675 }
676 }
677}