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    #[serde(with = "crate::serde_timestamp::utc")]
146    pub timestamp: DateTime<Utc>,
147    /// Severity of this specific violation
148    pub severity: RiskLevel,
149}
150
151impl SodViolation {
152    /// Create a new SoD violation record.
153    pub fn new(
154        conflict_type: SodConflictType,
155        actor_id: impl Into<String>,
156        conflicting_action: impl Into<String>,
157        severity: RiskLevel,
158    ) -> Self {
159        Self {
160            conflict_type,
161            actor_id: actor_id.into(),
162            conflicting_action: conflicting_action.into(),
163            timestamp: Utc::now(),
164            severity,
165        }
166    }
167
168    /// Create a violation with a specific timestamp.
169    pub fn with_timestamp(
170        conflict_type: SodConflictType,
171        actor_id: impl Into<String>,
172        conflicting_action: impl Into<String>,
173        severity: RiskLevel,
174        timestamp: DateTime<Utc>,
175    ) -> Self {
176        Self {
177            conflict_type,
178            actor_id: actor_id.into(),
179            conflicting_action: conflicting_action.into(),
180            timestamp,
181            severity,
182        }
183    }
184}
185
186/// SoD rule that defines what constitutes a conflict.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SodRule {
189    /// Rule identifier
190    pub rule_id: String,
191    /// Rule name
192    pub name: String,
193    /// Conflict type this rule detects
194    pub conflict_type: SodConflictType,
195    /// Description of the rule
196    pub description: String,
197    /// Whether this rule is active
198    pub is_active: bool,
199    /// Risk level if this rule is violated
200    pub risk_level: RiskLevel,
201}
202
203impl SodRule {
204    /// Create a new SoD rule.
205    pub fn new(
206        rule_id: impl Into<String>,
207        name: impl Into<String>,
208        conflict_type: SodConflictType,
209    ) -> Self {
210        Self {
211            rule_id: rule_id.into(),
212            name: name.into(),
213            conflict_type,
214            description: String::new(),
215            is_active: true,
216            risk_level: RiskLevel::High,
217        }
218    }
219
220    /// Builder method to set description.
221    pub fn with_description(mut self, description: impl Into<String>) -> Self {
222        self.description = description.into();
223        self
224    }
225
226    /// Builder method to set risk level.
227    pub fn with_risk_level(mut self, level: RiskLevel) -> Self {
228        self.risk_level = level;
229        self
230    }
231
232    /// Get standard SoD rules.
233    pub fn standard_rules() -> Vec<Self> {
234        vec![
235            Self::new(
236                "SOD001",
237                "Preparer-Approver Conflict",
238                SodConflictType::PreparerApprover,
239            )
240            .with_description("User cannot approve their own journal entries")
241            .with_risk_level(RiskLevel::High),
242            Self::new(
243                "SOD002",
244                "Payment Dual Control",
245                SodConflictType::PaymentReleaser,
246            )
247            .with_description("User cannot both create and release the same payment")
248            .with_risk_level(RiskLevel::Critical),
249            Self::new(
250                "SOD003",
251                "Vendor Master-Payment Conflict",
252                SodConflictType::MasterDataMaintainer,
253            )
254            .with_description("User cannot maintain vendor master data and process payments")
255            .with_risk_level(RiskLevel::High),
256            Self::new(
257                "SOD004",
258                "Requester-Approver Conflict",
259                SodConflictType::RequesterApprover,
260            )
261            .with_description("User cannot approve their own requisitions or expenses")
262            .with_risk_level(RiskLevel::Critical),
263            Self::new(
264                "SOD005",
265                "Reconciler-Poster Conflict",
266                SodConflictType::ReconcilerPoster,
267            )
268            .with_description("User cannot both reconcile accounts and post adjusting entries")
269            .with_risk_level(RiskLevel::Medium),
270        ]
271    }
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_sod_conflict_display() {
281        assert_eq!(
282            SodConflictType::PreparerApprover.to_string(),
283            "Preparer/Approver"
284        );
285        assert_eq!(
286            SodConflictType::PaymentReleaser.to_string(),
287            "Payment Releaser"
288        );
289    }
290
291    #[test]
292    fn test_standard_conflicts() {
293        let conflicts = SodConflictPair::standard_conflicts();
294        assert!(!conflicts.is_empty());
295
296        // Should have critical severity conflicts
297        let critical: Vec<_> = conflicts
298            .iter()
299            .filter(|c| c.severity == RiskLevel::Critical)
300            .collect();
301        assert!(!critical.is_empty());
302    }
303
304    #[test]
305    fn test_sod_violation_creation() {
306        let violation = SodViolation::new(
307            SodConflictType::PreparerApprover,
308            "USER001",
309            "Approved own journal entry",
310            RiskLevel::High,
311        );
312
313        assert_eq!(violation.actor_id, "USER001");
314        assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
315    }
316
317    #[test]
318    fn test_standard_rules() {
319        let rules = SodRule::standard_rules();
320        assert!(!rules.is_empty());
321
322        // All standard rules should be active
323        assert!(rules.iter().all(|r| r.is_active));
324    }
325}