Skip to main content

datasynth_core/models/
sod.rs

1//! Segregation of Duties (SoD) definitions and conflict detection.
2//!
3//! Implements SoD conflict types and rules commonly used in
4//! SOX compliance and internal audit frameworks.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use super::internal_control::RiskLevel;
10use super::user::UserPersona;
11
12/// Types of Segregation of Duties conflicts.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SodConflictType {
16    /// Same person prepared and approved a transaction
17    PreparerApprover,
18    /// Same person requested and approved their own request
19    RequesterApprover,
20    /// Same person performed reconciliation and posted entries
21    ReconcilerPoster,
22    /// Same person maintains vendor master data and processes payments
23    MasterDataMaintainer,
24    /// Same person created and released a payment
25    PaymentReleaser,
26    /// Same person posted to sensitive accounts without independent review
27    JournalEntryPoster,
28    /// Same person has access to multiple conflicting functions
29    SystemAccessConflict,
30}
31
32impl std::fmt::Display for SodConflictType {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::PreparerApprover => write!(f, "Preparer/Approver"),
36            Self::RequesterApprover => write!(f, "Requester/Approver"),
37            Self::ReconcilerPoster => write!(f, "Reconciler/Poster"),
38            Self::MasterDataMaintainer => write!(f, "Master Data Maintainer"),
39            Self::PaymentReleaser => write!(f, "Payment Releaser"),
40            Self::JournalEntryPoster => write!(f, "Journal Entry Poster"),
41            Self::SystemAccessConflict => write!(f, "System Access Conflict"),
42        }
43    }
44}
45
46/// Definition of a SoD conflict pair.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SodConflictPair {
49    /// Type of conflict
50    pub conflict_type: SodConflictType,
51    /// First role in the conflict
52    pub role_a: UserPersona,
53    /// Second role in the conflict (can be same as role_a)
54    pub role_b: UserPersona,
55    /// Description of the conflict
56    pub description: String,
57    /// Severity of this conflict type
58    pub severity: RiskLevel,
59}
60
61impl SodConflictPair {
62    /// Create a new SoD conflict pair.
63    pub fn new(
64        conflict_type: SodConflictType,
65        role_a: UserPersona,
66        role_b: UserPersona,
67        description: impl Into<String>,
68        severity: RiskLevel,
69    ) -> Self {
70        Self {
71            conflict_type,
72            role_a,
73            role_b,
74            description: description.into(),
75            severity,
76        }
77    }
78
79    /// Get standard SoD conflict pairs.
80    pub fn standard_conflicts() -> Vec<Self> {
81        vec![
82            Self::new(
83                SodConflictType::PreparerApprover,
84                UserPersona::JuniorAccountant,
85                UserPersona::SeniorAccountant,
86                "Same person prepared and approved journal entry",
87                RiskLevel::High,
88            ),
89            Self::new(
90                SodConflictType::PreparerApprover,
91                UserPersona::SeniorAccountant,
92                UserPersona::Controller,
93                "Same person prepared and approved high-value transaction",
94                RiskLevel::High,
95            ),
96            Self::new(
97                SodConflictType::RequesterApprover,
98                UserPersona::JuniorAccountant,
99                UserPersona::Manager,
100                "Same person requested and approved their own expense/requisition",
101                RiskLevel::Critical,
102            ),
103            Self::new(
104                SodConflictType::PaymentReleaser,
105                UserPersona::SeniorAccountant,
106                UserPersona::SeniorAccountant,
107                "Same person created and released payment",
108                RiskLevel::Critical,
109            ),
110            Self::new(
111                SodConflictType::MasterDataMaintainer,
112                UserPersona::SeniorAccountant,
113                UserPersona::JuniorAccountant,
114                "Same person maintains vendor master and processes payments",
115                RiskLevel::High,
116            ),
117            Self::new(
118                SodConflictType::ReconcilerPoster,
119                UserPersona::JuniorAccountant,
120                UserPersona::JuniorAccountant,
121                "Same person performed account reconciliation and posted adjustments",
122                RiskLevel::Medium,
123            ),
124            Self::new(
125                SodConflictType::JournalEntryPoster,
126                UserPersona::JuniorAccountant,
127                UserPersona::JuniorAccountant,
128                "Posted to sensitive GL accounts without independent review",
129                RiskLevel::High,
130            ),
131        ]
132    }
133}
134
135/// Record of a specific SoD violation on a transaction.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SodViolation {
138    /// Type of conflict that occurred
139    pub conflict_type: SodConflictType,
140    /// User ID who caused the violation
141    pub actor_id: String,
142    /// Description of the conflicting action
143    pub conflicting_action: String,
144    /// When the violation occurred
145    pub timestamp: DateTime<Utc>,
146    /// Severity of this specific violation
147    pub severity: RiskLevel,
148}
149
150impl SodViolation {
151    /// Create a new SoD violation record.
152    pub fn new(
153        conflict_type: SodConflictType,
154        actor_id: impl Into<String>,
155        conflicting_action: impl Into<String>,
156        severity: RiskLevel,
157    ) -> Self {
158        Self {
159            conflict_type,
160            actor_id: actor_id.into(),
161            conflicting_action: conflicting_action.into(),
162            timestamp: Utc::now(),
163            severity,
164        }
165    }
166
167    /// Create a violation with a specific timestamp.
168    pub fn with_timestamp(
169        conflict_type: SodConflictType,
170        actor_id: impl Into<String>,
171        conflicting_action: impl Into<String>,
172        severity: RiskLevel,
173        timestamp: DateTime<Utc>,
174    ) -> Self {
175        Self {
176            conflict_type,
177            actor_id: actor_id.into(),
178            conflicting_action: conflicting_action.into(),
179            timestamp,
180            severity,
181        }
182    }
183}
184
185/// SoD rule that defines what constitutes a conflict.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SodRule {
188    /// Rule identifier
189    pub rule_id: String,
190    /// Rule name
191    pub name: String,
192    /// Conflict type this rule detects
193    pub conflict_type: SodConflictType,
194    /// Description of the rule
195    pub description: String,
196    /// Whether this rule is active
197    pub is_active: bool,
198    /// Risk level if this rule is violated
199    pub risk_level: RiskLevel,
200}
201
202impl SodRule {
203    /// Create a new SoD rule.
204    pub fn new(
205        rule_id: impl Into<String>,
206        name: impl Into<String>,
207        conflict_type: SodConflictType,
208    ) -> Self {
209        Self {
210            rule_id: rule_id.into(),
211            name: name.into(),
212            conflict_type,
213            description: String::new(),
214            is_active: true,
215            risk_level: RiskLevel::High,
216        }
217    }
218
219    /// Builder method to set description.
220    pub fn with_description(mut self, description: impl Into<String>) -> Self {
221        self.description = description.into();
222        self
223    }
224
225    /// Builder method to set risk level.
226    pub fn with_risk_level(mut self, level: RiskLevel) -> Self {
227        self.risk_level = level;
228        self
229    }
230
231    /// Get standard SoD rules.
232    pub fn standard_rules() -> Vec<Self> {
233        vec![
234            Self::new(
235                "SOD001",
236                "Preparer-Approver Conflict",
237                SodConflictType::PreparerApprover,
238            )
239            .with_description("User cannot approve their own journal entries")
240            .with_risk_level(RiskLevel::High),
241            Self::new(
242                "SOD002",
243                "Payment Dual Control",
244                SodConflictType::PaymentReleaser,
245            )
246            .with_description("User cannot both create and release the same payment")
247            .with_risk_level(RiskLevel::Critical),
248            Self::new(
249                "SOD003",
250                "Vendor Master-Payment Conflict",
251                SodConflictType::MasterDataMaintainer,
252            )
253            .with_description("User cannot maintain vendor master data and process payments")
254            .with_risk_level(RiskLevel::High),
255            Self::new(
256                "SOD004",
257                "Requester-Approver Conflict",
258                SodConflictType::RequesterApprover,
259            )
260            .with_description("User cannot approve their own requisitions or expenses")
261            .with_risk_level(RiskLevel::Critical),
262            Self::new(
263                "SOD005",
264                "Reconciler-Poster Conflict",
265                SodConflictType::ReconcilerPoster,
266            )
267            .with_description("User cannot both reconcile accounts and post adjusting entries")
268            .with_risk_level(RiskLevel::Medium),
269        ]
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_sod_conflict_display() {
279        assert_eq!(
280            SodConflictType::PreparerApprover.to_string(),
281            "Preparer/Approver"
282        );
283        assert_eq!(
284            SodConflictType::PaymentReleaser.to_string(),
285            "Payment Releaser"
286        );
287    }
288
289    #[test]
290    fn test_standard_conflicts() {
291        let conflicts = SodConflictPair::standard_conflicts();
292        assert!(!conflicts.is_empty());
293
294        // Should have critical severity conflicts
295        let critical: Vec<_> = conflicts
296            .iter()
297            .filter(|c| c.severity == RiskLevel::Critical)
298            .collect();
299        assert!(!critical.is_empty());
300    }
301
302    #[test]
303    fn test_sod_violation_creation() {
304        let violation = SodViolation::new(
305            SodConflictType::PreparerApprover,
306            "USER001",
307            "Approved own journal entry",
308            RiskLevel::High,
309        );
310
311        assert_eq!(violation.actor_id, "USER001");
312        assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
313    }
314
315    #[test]
316    fn test_standard_rules() {
317        let rules = SodRule::standard_rules();
318        assert!(!rules.is_empty());
319
320        // All standard rules should be active
321        assert!(rules.iter().all(|r| r.is_active));
322    }
323}