datasynth_generators/audit/
service_org_generator.rs1use chrono::{Duration, NaiveDate};
8use datasynth_core::models::audit::service_organization::{
9 ControlEffectiveness, ControlObjective, ServiceOrganization, ServiceType, SocException,
10 SocOpinionType, SocReport, SocReportType, UserEntityControl,
11};
12use datasynth_core::utils::seeded_rng;
13use rand::Rng;
14use rand_chacha::ChaCha8Rng;
15
16#[derive(Debug, Clone)]
18pub struct ServiceOrgGeneratorConfig {
19 pub service_orgs_per_entity: (usize, usize),
21 pub objectives_per_report: (usize, usize),
23 pub exceptions_per_report: (usize, usize),
25 pub qualified_opinion_probability: f64,
27 pub user_controls_per_report: (usize, usize),
29}
30
31impl Default for ServiceOrgGeneratorConfig {
32 fn default() -> Self {
33 Self {
34 service_orgs_per_entity: (1, 3),
35 objectives_per_report: (3, 8),
36 exceptions_per_report: (0, 2),
37 qualified_opinion_probability: 0.10,
38 user_controls_per_report: (1, 4),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct ServiceOrgSnapshot {
46 pub service_organizations: Vec<ServiceOrganization>,
48 pub soc_reports: Vec<SocReport>,
50 pub user_entity_controls: Vec<UserEntityControl>,
52}
53
54pub struct ServiceOrgGenerator {
56 rng: ChaCha8Rng,
57 config: ServiceOrgGeneratorConfig,
58}
59
60impl ServiceOrgGenerator {
61 pub fn new(seed: u64) -> Self {
63 Self {
64 rng: seeded_rng(seed, 0x402),
65 config: ServiceOrgGeneratorConfig::default(),
66 }
67 }
68
69 pub fn with_config(seed: u64, config: ServiceOrgGeneratorConfig) -> Self {
71 Self {
72 rng: seeded_rng(seed, 0x402),
73 config,
74 }
75 }
76
77 pub fn generate(
79 &mut self,
80 entity_codes: &[String],
81 period_end_date: NaiveDate,
82 ) -> ServiceOrgSnapshot {
83 if entity_codes.is_empty() {
84 return ServiceOrgSnapshot::default();
85 }
86
87 let mut snapshot = ServiceOrgSnapshot::default();
88
89 let service_type_pool = [
91 ServiceType::PayrollProcessor,
92 ServiceType::CloudHosting,
93 ServiceType::PaymentProcessor,
94 ServiceType::ItManagedServices,
95 ServiceType::DataCentre,
96 ];
97
98 for entity_code in entity_codes {
99 let org_count = self.rng.random_range(
100 self.config.service_orgs_per_entity.0..=self.config.service_orgs_per_entity.1,
101 );
102
103 for i in 0..org_count {
104 let service_type = service_type_pool[i % service_type_pool.len()];
105 let org_name = self.org_name(service_type, i);
106
107 let org_id = if let Some(existing) = snapshot
109 .service_organizations
110 .iter_mut()
111 .find(|o| o.service_type == service_type && o.name == org_name)
112 {
113 existing.entities_served.push(entity_code.clone());
114 existing.id.clone()
115 } else {
116 let org =
117 ServiceOrganization::new(org_name, service_type, vec![entity_code.clone()]);
118 let id = org.id.clone();
119 snapshot.service_organizations.push(org);
120 id
121 };
122
123 let report = self.generate_soc_report(&org_id, period_end_date);
125 let report_id = report.id.clone();
126 let objective_ids: Vec<String> = report
127 .control_objectives
128 .iter()
129 .map(|o| o.id.clone())
130 .collect();
131 snapshot.soc_reports.push(report);
132
133 let user_controls =
135 self.generate_user_controls(&report_id, &objective_ids, entity_code);
136 snapshot.user_entity_controls.extend(user_controls);
137 }
138 }
139
140 snapshot
141 }
142
143 fn generate_soc_report(
144 &mut self,
145 service_org_id: &str,
146 period_end_date: NaiveDate,
147 ) -> SocReport {
148 let objectives_count = self.rng.random_range(
149 self.config.objectives_per_report.0..=self.config.objectives_per_report.1,
150 );
151 let exceptions_count = self.rng.random_range(
152 self.config.exceptions_per_report.0..=self.config.exceptions_per_report.1,
153 );
154
155 let has_exceptions = exceptions_count > 0;
156 let opinion_type = if has_exceptions
157 && self.rng.random::<f64>() < self.config.qualified_opinion_probability
158 {
159 SocOpinionType::Qualified
160 } else {
161 SocOpinionType::Unmodified
162 };
163
164 let report_period_start = period_end_date - Duration::days(365);
166 let report_period_end = period_end_date;
167
168 let mut report = SocReport::new(
169 service_org_id,
170 SocReportType::Soc1Type2,
171 report_period_start,
172 report_period_end,
173 opinion_type,
174 );
175
176 for j in 0..objectives_count {
178 let controls_tested = self.rng.random_range(3u32..=12);
179 let controls_effective = !(has_exceptions && j < exceptions_count);
181 let description = self.objective_description(j);
182 let objective = ControlObjective::new(description, controls_tested, controls_effective);
183 report.control_objectives.push(objective);
184 }
185
186 let ineffective_objectives: Vec<String> = report
188 .control_objectives
189 .iter()
190 .filter(|o| !o.controls_effective)
191 .map(|o| o.id.clone())
192 .collect();
193
194 for obj_id in &ineffective_objectives {
195 let exception = SocException {
196 control_objective_id: obj_id.clone(),
197 description: "A sample of transactions tested revealed that the control did not \
198 operate as designed during the period."
199 .to_string(),
200 management_response: "Management has implemented enhanced monitoring procedures \
201 to address the identified control deficiency."
202 .to_string(),
203 user_entity_impact: "User entities should consider compensating controls to \
204 address the risk arising from this exception."
205 .to_string(),
206 };
207 report.exceptions_noted.push(exception);
208 }
209
210 report
211 }
212
213 fn generate_user_controls(
214 &mut self,
215 soc_report_id: &str,
216 objective_ids: &[String],
217 _entity_code: &str,
218 ) -> Vec<UserEntityControl> {
219 if objective_ids.is_empty() {
220 return Vec::new();
221 }
222
223 let count = self.rng.random_range(
224 self.config.user_controls_per_report.0..=self.config.user_controls_per_report.1,
225 );
226
227 let mut controls = Vec::with_capacity(count);
228 for i in 0..count {
229 let mapped_objective = &objective_ids[i % objective_ids.len()];
230 let implemented = self.rng.random::<f64>() < 0.90;
231 let effectiveness = if implemented {
232 if self.rng.random::<f64>() < 0.80 {
233 ControlEffectiveness::Effective
234 } else {
235 ControlEffectiveness::EffectiveWithExceptions
236 }
237 } else {
238 ControlEffectiveness::NotTested
239 };
240
241 let description = self.user_control_description(i);
242 let control = UserEntityControl::new(
243 soc_report_id,
244 description,
245 mapped_objective,
246 implemented,
247 effectiveness,
248 );
249 controls.push(control);
250 }
251
252 controls
253 }
254
255 fn org_name(&self, service_type: ServiceType, index: usize) -> String {
256 let names_by_type: &[&str] = match service_type {
257 ServiceType::PayrollProcessor => &[
258 "Ceridian HCM Inc.",
259 "ADP Employer Services",
260 "Paychex Inc.",
261 "Workday Payroll Ltd.",
262 ],
263 ServiceType::CloudHosting => &[
264 "Amazon Web Services Inc.",
265 "Microsoft Azure Cloud",
266 "Google Cloud Platform",
267 "IBM Cloud Services",
268 ],
269 ServiceType::PaymentProcessor => &[
270 "Stripe Inc.",
271 "PayPal Holdings Inc.",
272 "Worldpay Group Ltd.",
273 "Adyen N.V.",
274 ],
275 ServiceType::ItManagedServices => &[
276 "DXC Technology Co.",
277 "Unisys Corporation",
278 "Cognizant IT Solutions",
279 "Infosys BPM Ltd.",
280 ],
281 ServiceType::DataCentre => &[
282 "Equinix Inc.",
283 "Digital Realty Trust",
284 "CyrusOne LLC",
285 "Iron Mountain Data Centres",
286 ],
287 };
288 names_by_type[index % names_by_type.len()].to_string()
289 }
290
291 fn objective_description(&self, index: usize) -> String {
292 let objectives = [
293 "Logical access controls over applications and data are designed and operating effectively.",
294 "Change management procedures ensure that programme changes are authorised, tested, and approved.",
295 "Computer operations controls ensure that processing is complete, accurate, and timely.",
296 "Data backup and recovery controls ensure data integrity and availability.",
297 "Network and security controls protect systems from unauthorised access.",
298 "Incident management controls ensure that security incidents are identified and resolved.",
299 "Vendor management controls ensure that third-party risks are assessed and monitored.",
300 "Physical security controls restrict access to data processing facilities.",
301 ];
302 objectives[index % objectives.len()].to_string()
303 }
304
305 fn user_control_description(&self, index: usize) -> String {
306 let descriptions = [
307 "Review of user access rights at least annually and removal of access for terminated employees.",
308 "Reconciliation of payroll data transmitted to the service organization and results received.",
309 "Monitoring of service organization performance metrics and escalation of issues.",
310 "Review and approval of changes to master data transmitted to the service organization.",
311 "Periodic review of SOC reports and assessment of exceptions on user entity operations.",
312 ];
313 descriptions[index % descriptions.len()].to_string()
314 }
315}
316
317#[cfg(test)]
318#[allow(clippy::unwrap_used)]
319mod tests {
320 use super::*;
321
322 fn period_end() -> NaiveDate {
323 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()
324 }
325
326 fn entity_codes(n: usize) -> Vec<String> {
327 (1..=n).map(|i| format!("C{i:03}")).collect()
328 }
329
330 #[test]
331 fn test_service_orgs_within_bounds() {
332 let mut gen = ServiceOrgGenerator::new(42);
333 let snapshot = gen.generate(&entity_codes(1), period_end());
334 assert!(
335 snapshot.service_organizations.len() >= 1 && snapshot.service_organizations.len() <= 3,
336 "expected 1-3 service orgs, got {}",
337 snapshot.service_organizations.len()
338 );
339 }
340
341 #[test]
342 fn test_soc_reports_have_objectives_in_range() {
343 let mut gen = ServiceOrgGenerator::new(42);
344 let snapshot = gen.generate(&entity_codes(2), period_end());
345 for report in &snapshot.soc_reports {
346 assert!(
347 report.control_objectives.len() >= 3 && report.control_objectives.len() <= 8,
348 "expected 3-8 control objectives, got {}",
349 report.control_objectives.len()
350 );
351 }
352 }
353
354 #[test]
355 fn test_exceptions_within_bounds() {
356 let mut gen = ServiceOrgGenerator::new(42);
357 let snapshot = gen.generate(&entity_codes(3), period_end());
358 for report in &snapshot.soc_reports {
359 assert!(
360 report.exceptions_noted.len() <= 2,
361 "expected 0-2 exceptions, got {}",
362 report.exceptions_noted.len()
363 );
364 }
365 }
366
367 #[test]
368 fn test_user_entity_controls_reference_valid_reports() {
369 use std::collections::HashSet;
370 let mut gen = ServiceOrgGenerator::new(42);
371 let snapshot = gen.generate(&entity_codes(2), period_end());
372
373 let report_ids: HashSet<String> =
374 snapshot.soc_reports.iter().map(|r| r.id.clone()).collect();
375
376 for ctrl in &snapshot.user_entity_controls {
377 assert!(
378 report_ids.contains(&ctrl.soc_report_id),
379 "UserEntityControl references unknown soc_report_id '{}'",
380 ctrl.soc_report_id
381 );
382 }
383 }
384
385 #[test]
386 fn test_empty_entities_returns_empty_snapshot() {
387 let mut gen = ServiceOrgGenerator::new(42);
388 let snapshot = gen.generate(&[], period_end());
389 assert!(snapshot.service_organizations.is_empty());
390 assert!(snapshot.soc_reports.is_empty());
391 assert!(snapshot.user_entity_controls.is_empty());
392 }
393}