use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::internal_control::RiskLevel;
use super::user::UserPersona;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SodConflictType {
PreparerApprover,
RequesterApprover,
ReconcilerPoster,
MasterDataMaintainer,
PaymentReleaser,
JournalEntryPoster,
SystemAccessConflict,
}
impl std::fmt::Display for SodConflictType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PreparerApprover => write!(f, "Preparer/Approver"),
Self::RequesterApprover => write!(f, "Requester/Approver"),
Self::ReconcilerPoster => write!(f, "Reconciler/Poster"),
Self::MasterDataMaintainer => write!(f, "Master Data Maintainer"),
Self::PaymentReleaser => write!(f, "Payment Releaser"),
Self::JournalEntryPoster => write!(f, "Journal Entry Poster"),
Self::SystemAccessConflict => write!(f, "System Access Conflict"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SodConflictPair {
pub conflict_type: SodConflictType,
pub role_a: UserPersona,
pub role_b: UserPersona,
pub description: String,
pub severity: RiskLevel,
}
impl SodConflictPair {
pub fn new(
conflict_type: SodConflictType,
role_a: UserPersona,
role_b: UserPersona,
description: impl Into<String>,
severity: RiskLevel,
) -> Self {
Self {
conflict_type,
role_a,
role_b,
description: description.into(),
severity,
}
}
pub fn standard_conflicts() -> Vec<Self> {
vec![
Self::new(
SodConflictType::PreparerApprover,
UserPersona::JuniorAccountant,
UserPersona::SeniorAccountant,
"Same person prepared and approved journal entry",
RiskLevel::High,
),
Self::new(
SodConflictType::PreparerApprover,
UserPersona::SeniorAccountant,
UserPersona::Controller,
"Same person prepared and approved high-value transaction",
RiskLevel::High,
),
Self::new(
SodConflictType::RequesterApprover,
UserPersona::JuniorAccountant,
UserPersona::Manager,
"Same person requested and approved their own expense/requisition",
RiskLevel::Critical,
),
Self::new(
SodConflictType::PaymentReleaser,
UserPersona::SeniorAccountant,
UserPersona::SeniorAccountant,
"Same person created and released payment",
RiskLevel::Critical,
),
Self::new(
SodConflictType::MasterDataMaintainer,
UserPersona::SeniorAccountant,
UserPersona::JuniorAccountant,
"Same person maintains vendor master and processes payments",
RiskLevel::High,
),
Self::new(
SodConflictType::ReconcilerPoster,
UserPersona::JuniorAccountant,
UserPersona::JuniorAccountant,
"Same person performed account reconciliation and posted adjustments",
RiskLevel::Medium,
),
Self::new(
SodConflictType::JournalEntryPoster,
UserPersona::JuniorAccountant,
UserPersona::JuniorAccountant,
"Posted to sensitive GL accounts without independent review",
RiskLevel::High,
),
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SodViolation {
pub conflict_type: SodConflictType,
pub actor_id: String,
pub conflicting_action: String,
#[serde(with = "crate::serde_timestamp::utc")]
pub timestamp: DateTime<Utc>,
pub severity: RiskLevel,
}
impl SodViolation {
pub fn new(
conflict_type: SodConflictType,
actor_id: impl Into<String>,
conflicting_action: impl Into<String>,
severity: RiskLevel,
) -> Self {
Self {
conflict_type,
actor_id: actor_id.into(),
conflicting_action: conflicting_action.into(),
timestamp: Utc::now(),
severity,
}
}
pub fn with_timestamp(
conflict_type: SodConflictType,
actor_id: impl Into<String>,
conflicting_action: impl Into<String>,
severity: RiskLevel,
timestamp: DateTime<Utc>,
) -> Self {
Self {
conflict_type,
actor_id: actor_id.into(),
conflicting_action: conflicting_action.into(),
timestamp,
severity,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SodRule {
pub rule_id: String,
pub name: String,
pub conflict_type: SodConflictType,
pub description: String,
pub is_active: bool,
pub risk_level: RiskLevel,
}
impl SodRule {
pub fn new(
rule_id: impl Into<String>,
name: impl Into<String>,
conflict_type: SodConflictType,
) -> Self {
Self {
rule_id: rule_id.into(),
name: name.into(),
conflict_type,
description: String::new(),
is_active: true,
risk_level: RiskLevel::High,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn with_risk_level(mut self, level: RiskLevel) -> Self {
self.risk_level = level;
self
}
pub fn standard_rules() -> Vec<Self> {
vec![
Self::new(
"SOD001",
"Preparer-Approver Conflict",
SodConflictType::PreparerApprover,
)
.with_description("User cannot approve their own journal entries")
.with_risk_level(RiskLevel::High),
Self::new(
"SOD002",
"Payment Dual Control",
SodConflictType::PaymentReleaser,
)
.with_description("User cannot both create and release the same payment")
.with_risk_level(RiskLevel::Critical),
Self::new(
"SOD003",
"Vendor Master-Payment Conflict",
SodConflictType::MasterDataMaintainer,
)
.with_description("User cannot maintain vendor master data and process payments")
.with_risk_level(RiskLevel::High),
Self::new(
"SOD004",
"Requester-Approver Conflict",
SodConflictType::RequesterApprover,
)
.with_description("User cannot approve their own requisitions or expenses")
.with_risk_level(RiskLevel::Critical),
Self::new(
"SOD005",
"Reconciler-Poster Conflict",
SodConflictType::ReconcilerPoster,
)
.with_description("User cannot both reconcile accounts and post adjusting entries")
.with_risk_level(RiskLevel::Medium),
]
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_sod_conflict_display() {
assert_eq!(
SodConflictType::PreparerApprover.to_string(),
"Preparer/Approver"
);
assert_eq!(
SodConflictType::PaymentReleaser.to_string(),
"Payment Releaser"
);
}
#[test]
fn test_standard_conflicts() {
let conflicts = SodConflictPair::standard_conflicts();
assert!(!conflicts.is_empty());
let critical: Vec<_> = conflicts
.iter()
.filter(|c| c.severity == RiskLevel::Critical)
.collect();
assert!(!critical.is_empty());
}
#[test]
fn test_sod_violation_creation() {
let violation = SodViolation::new(
SodConflictType::PreparerApprover,
"USER001",
"Approved own journal entry",
RiskLevel::High,
);
assert_eq!(violation.actor_id, "USER001");
assert_eq!(violation.conflict_type, SodConflictType::PreparerApprover);
}
#[test]
fn test_standard_rules() {
let rules = SodRule::standard_rules();
assert!(!rules.is_empty());
assert!(rules.iter().all(|r| r.is_active));
}
}