Skip to main content

datasynth_generators/audit/
service_org_generator.rs

1//! Service organization and SOC report generator per ISA 402.
2//!
3//! Generates 1–3 service organizations per entity and produces SOC 1 Type II
4//! reports with 3–8 control objectives and 0–2 exceptions per report.
5//! User entity controls are generated mapping back to SOC objectives.
6
7use 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/// Configuration for service organization generation.
17#[derive(Debug, Clone)]
18pub struct ServiceOrgGeneratorConfig {
19    /// Number of service organizations per entity (min, max)
20    pub service_orgs_per_entity: (usize, usize),
21    /// Number of control objectives per SOC report (min, max)
22    pub objectives_per_report: (usize, usize),
23    /// Number of exceptions per report (min, max)
24    pub exceptions_per_report: (usize, usize),
25    /// Probability of a qualified opinion (vs unmodified)
26    pub qualified_opinion_probability: f64,
27    /// Number of user entity controls per SOC report (min, max)
28    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/// Result of generating service organization data for a set of entities.
44#[derive(Debug, Clone, Default)]
45pub struct ServiceOrgSnapshot {
46    /// Service organizations identified
47    pub service_organizations: Vec<ServiceOrganization>,
48    /// SOC reports obtained
49    pub soc_reports: Vec<SocReport>,
50    /// User entity controls documented
51    pub user_entity_controls: Vec<UserEntityControl>,
52}
53
54/// Generator for ISA 402 service organization controls.
55pub struct ServiceOrgGenerator {
56    rng: ChaCha8Rng,
57    config: ServiceOrgGeneratorConfig,
58}
59
60impl ServiceOrgGenerator {
61    /// Create a new generator with the given seed.
62    pub fn new(seed: u64) -> Self {
63        Self {
64            rng: seeded_rng(seed, 0x402),
65            config: ServiceOrgGeneratorConfig::default(),
66        }
67    }
68
69    /// Create a new generator with custom configuration.
70    pub fn with_config(seed: u64, config: ServiceOrgGeneratorConfig) -> Self {
71        Self {
72            rng: seeded_rng(seed, 0x402),
73            config,
74        }
75    }
76
77    /// Generate service organizations and SOC reports for a list of entities.
78    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        // Pool of service type templates to draw from
90        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                // Check if a matching service org already exists (reuse across entities)
108                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                // Generate a SOC 1 Type II report for this org/entity pair
124                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                // Generate user entity controls for the report
134                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        // SOC report covers the 12 months ending at period-end
165        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        // Generate control objectives
177        for j in 0..objectives_count {
178            let controls_tested = self.rng.random_range(3u32..=12);
179            // Objectives with exceptions may have ineffective controls
180            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        // Generate exceptions for objectives that have failures
187        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}