use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use super::approval::ApprovalRequest;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Hash, Default,
)]
pub enum RiskLevel {
None,
Low,
#[default]
Medium,
High,
Critical,
}
impl RiskLevel {
pub fn display_name(&self) -> &'static str {
match self {
RiskLevel::None => "None",
RiskLevel::Low => "Low",
RiskLevel::Medium => "Medium",
RiskLevel::High => "High",
RiskLevel::Critical => "Critical",
}
}
pub fn color(&self) -> &'static str {
match self {
RiskLevel::None => "green",
RiskLevel::Low => "blue",
RiskLevel::Medium => "yellow",
RiskLevel::High => "orange",
RiskLevel::Critical => "red",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum ActionType {
FileRead,
FileWrite,
FileDelete,
SystemCommand,
Deploy,
DatabaseModify,
NetworkRequest,
GitOperation,
EnvChange,
ConfigChange,
ApiCall,
Custom,
}
impl ActionType {
pub fn default_risk(&self) -> RiskLevel {
match self {
ActionType::FileRead => RiskLevel::None,
ActionType::FileWrite => RiskLevel::Low,
ActionType::FileDelete => RiskLevel::High,
ActionType::SystemCommand => RiskLevel::High,
ActionType::Deploy => RiskLevel::Critical,
ActionType::DatabaseModify => RiskLevel::Critical,
ActionType::NetworkRequest => RiskLevel::Medium,
ActionType::GitOperation => RiskLevel::Medium,
ActionType::EnvChange => RiskLevel::High,
ActionType::ConfigChange => RiskLevel::High,
ActionType::ApiCall => RiskLevel::Medium,
ActionType::Custom => RiskLevel::Medium,
}
}
pub fn display_name(&self) -> &'static str {
match self {
ActionType::FileRead => "File Read",
ActionType::FileWrite => "File Write",
ActionType::FileDelete => "File Delete",
ActionType::SystemCommand => "System Command",
ActionType::Deploy => "Deploy",
ActionType::DatabaseModify => "Database Modify",
ActionType::NetworkRequest => "Network Request",
ActionType::GitOperation => "Git Operation",
ActionType::EnvChange => "Environment Change",
ActionType::ConfigChange => "Config Change",
ActionType::ApiCall => "API Call",
ActionType::Custom => "Custom",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalPolicy {
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub priority: i32,
pub rules: Vec<PolicyRule>,
pub required_approvers: Vec<String>,
pub min_approvals: usize,
pub allowed_approvers: HashSet<String>,
}
impl ApprovalPolicy {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
enabled: true,
priority: 0,
rules: Vec::new(),
required_approvers: Vec::new(),
min_approvals: 1,
allowed_approvers: HashSet::new(),
}
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn with_rule(mut self, rule: PolicyRule) -> Self {
self.rules.push(rule);
self
}
pub fn with_min_approvals(mut self, min: usize) -> Self {
self.min_approvals = min;
self
}
pub fn with_required_approver(mut self, approver: impl Into<String>) -> Self {
self.required_approvers.push(approver.into());
self
}
pub fn with_allowed_approver(mut self, approver: impl Into<String>) -> Self {
self.allowed_approvers.insert(approver.into());
self
}
pub fn matches(&self, request: &ApprovalRequest) -> bool {
if !self.enabled {
return false;
}
self.rules.iter().any(|rule| rule.matches(request))
}
pub fn can_approve(&self, approver: &str) -> bool {
if self.allowed_approvers.is_empty() {
return true;
}
self.allowed_approvers.contains(approver)
}
pub fn all_required_approved(&self, approvers: &[String]) -> bool {
self.required_approvers
.iter()
.all(|req| approvers.contains(req))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PolicyRule {
RequireApproval {
action_types: Vec<ActionType>,
environments: Option<Vec<String>>,
},
RiskThreshold { min_level: RiskLevel },
FilePattern { patterns: Vec<String> },
CommandPattern { patterns: Vec<String> },
AgentRestriction { agent_ids: Vec<String> },
TimeRestriction {
require_during_hours: Vec<u32>,
require_during_days: Vec<u32>,
},
AlwaysRequire,
NeverRequire,
Custom { condition: String },
}
impl PolicyRule {
pub fn matches(&self, request: &ApprovalRequest) -> bool {
match self {
PolicyRule::RequireApproval {
action_types,
environments,
} => {
let type_matches = action_types.contains(&request.action_type);
let env_matches = environments.as_ref().is_none_or(|envs| {
request
.environment
.as_ref()
.is_some_and(|e| envs.contains(e))
});
type_matches && env_matches
}
PolicyRule::RiskThreshold { min_level } => request.risk_level >= *min_level,
PolicyRule::FilePattern { patterns } => request.affected_files.iter().any(|file| {
patterns
.iter()
.any(|pattern| file_matches_pattern(file, pattern))
}),
PolicyRule::CommandPattern { patterns } => request.commands.iter().any(|cmd| {
patterns
.iter()
.any(|pattern| command_matches_pattern(cmd, pattern))
}),
PolicyRule::AgentRestriction { agent_ids } => request
.agent_id
.as_ref()
.is_some_and(|id| agent_ids.contains(id)),
PolicyRule::TimeRestriction {
require_during_hours,
require_during_days,
} => {
let now = chrono::Utc::now();
let hour = now.format("%H").to_string().parse::<u32>().unwrap_or(0);
let day = now.format("%w").to_string().parse::<u32>().unwrap_or(0);
let hour_match =
require_during_hours.is_empty() || require_during_hours.contains(&hour);
let day_match =
require_during_days.is_empty() || require_during_days.contains(&day);
hour_match && day_match
}
PolicyRule::AlwaysRequire => true,
PolicyRule::NeverRequire => false,
PolicyRule::Custom { condition: _ } => {
false
}
}
}
}
fn file_matches_pattern(file: &str, pattern: &str) -> bool {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return file.starts_with(prefix) && file.ends_with(suffix);
}
}
file == pattern || file.ends_with(pattern)
}
fn command_matches_pattern(cmd: &str, pattern: &str) -> bool {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return cmd.starts_with(prefix) && cmd.ends_with(suffix);
}
}
cmd.contains(pattern)
}
#[allow(dead_code)]
pub struct PredefinedPolicies;
#[allow(dead_code)]
impl PredefinedPolicies {
pub fn production_deploy() -> ApprovalPolicy {
ApprovalPolicy::new("production_deploy")
.with_description("Require approval for production deployments")
.with_priority(100)
.with_rule(PolicyRule::RequireApproval {
action_types: vec![ActionType::Deploy],
environments: Some(vec!["production".to_string(), "prod".to_string()]),
})
}
pub fn critical_operations() -> ApprovalPolicy {
ApprovalPolicy::new("critical_operations")
.with_description("Require approval for critical risk operations")
.with_priority(90)
.with_rule(PolicyRule::RiskThreshold {
min_level: RiskLevel::Critical,
})
}
pub fn sensitive_files() -> ApprovalPolicy {
ApprovalPolicy::new("sensitive_files")
.with_description("Require approval for sensitive file modifications")
.with_priority(80)
.with_rule(PolicyRule::FilePattern {
patterns: vec![
".env".to_string(),
"*.key".to_string(),
"*.pem".to_string(),
"*secret*".to_string(),
"*password*".to_string(),
"credentials*".to_string(),
],
})
}
pub fn dangerous_commands() -> ApprovalPolicy {
ApprovalPolicy::new("dangerous_commands")
.with_description("Require approval for dangerous commands")
.with_priority(70)
.with_rule(PolicyRule::CommandPattern {
patterns: vec![
"rm -rf".to_string(),
"drop database".to_string(),
"DELETE FROM".to_string(),
"TRUNCATE".to_string(),
"git push --force".to_string(),
"git reset --hard".to_string(),
],
})
}
pub fn after_hours() -> ApprovalPolicy {
ApprovalPolicy::new("after_hours")
.with_description("Require approval outside business hours")
.with_priority(50)
.with_rule(PolicyRule::TimeRestriction {
require_during_hours: (0..8).chain(18..24).collect(),
require_during_days: vec![0, 6], })
}
pub fn all() -> Vec<ApprovalPolicy> {
vec![
Self::production_deploy(),
Self::critical_operations(),
Self::sensitive_files(),
Self::dangerous_commands(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_risk_level_ordering() {
assert!(RiskLevel::Critical > RiskLevel::High);
assert!(RiskLevel::High > RiskLevel::Medium);
assert!(RiskLevel::Medium > RiskLevel::Low);
assert!(RiskLevel::Low > RiskLevel::None);
}
#[test]
fn test_action_type_default_risk() {
assert_eq!(ActionType::FileRead.default_risk(), RiskLevel::None);
assert_eq!(ActionType::Deploy.default_risk(), RiskLevel::Critical);
assert_eq!(ActionType::FileDelete.default_risk(), RiskLevel::High);
}
#[test]
fn test_policy_creation() {
let policy = ApprovalPolicy::new("test_policy")
.with_description("Test description")
.with_priority(10)
.with_rule(PolicyRule::RiskThreshold {
min_level: RiskLevel::High,
});
assert_eq!(policy.name, "test_policy");
assert_eq!(policy.priority, 10);
assert_eq!(policy.rules.len(), 1);
}
#[test]
fn test_policy_matching() {
let policy = ApprovalPolicy::new("production").with_rule(PolicyRule::RequireApproval {
action_types: vec![ActionType::Deploy],
environments: Some(vec!["production".to_string()]),
});
let request_prod = ApprovalRequest::new("Deploy", ActionType::Deploy, RiskLevel::High)
.with_environment("production");
let request_dev = ApprovalRequest::new("Deploy", ActionType::Deploy, RiskLevel::Medium)
.with_environment("development");
assert!(policy.matches(&request_prod));
assert!(!policy.matches(&request_dev));
}
#[test]
fn test_risk_threshold_rule() {
let rule = PolicyRule::RiskThreshold {
min_level: RiskLevel::High,
};
let high_risk = ApprovalRequest::new("Delete", ActionType::FileDelete, RiskLevel::High);
let low_risk = ApprovalRequest::new("Read", ActionType::FileRead, RiskLevel::Low);
assert!(rule.matches(&high_risk));
assert!(!rule.matches(&low_risk));
}
#[test]
fn test_file_pattern_rule() {
let rule = PolicyRule::FilePattern {
patterns: vec!["*.env".to_string(), ".secret*".to_string()],
};
let matches = ApprovalRequest::new("Edit", ActionType::FileWrite, RiskLevel::Medium)
.with_files(vec!["production.env".to_string()]);
let no_match = ApprovalRequest::new("Edit", ActionType::FileWrite, RiskLevel::Medium)
.with_files(vec!["README.md".to_string()]);
assert!(rule.matches(&matches));
assert!(!rule.matches(&no_match));
}
#[test]
fn test_command_pattern_rule() {
let rule = PolicyRule::CommandPattern {
patterns: vec!["rm -rf".to_string(), "drop database".to_string()],
};
let dangerous = ApprovalRequest::new("Delete", ActionType::SystemCommand, RiskLevel::High)
.with_commands(vec!["rm -rf /tmp/test".to_string()]);
let safe = ApprovalRequest::new("List", ActionType::SystemCommand, RiskLevel::Low)
.with_commands(vec!["ls -la".to_string()]);
assert!(rule.matches(&dangerous));
assert!(!rule.matches(&safe));
}
#[test]
fn test_file_matches_pattern() {
assert!(file_matches_pattern("config.env", "*.env"));
assert!(file_matches_pattern(".env", ".env"));
assert!(file_matches_pattern("src/secret.key", "*.key"));
assert!(!file_matches_pattern("readme.md", "*.env"));
}
#[test]
fn test_predefined_policies() {
let policies = PredefinedPolicies::all();
assert!(!policies.is_empty());
let production_policy = PredefinedPolicies::production_deploy();
assert_eq!(production_policy.name, "production_deploy");
}
#[test]
fn test_approver_restrictions() {
let policy = ApprovalPolicy::new("restricted")
.with_allowed_approver("admin")
.with_allowed_approver("lead");
assert!(policy.can_approve("admin"));
assert!(policy.can_approve("lead"));
assert!(!policy.can_approve("dev"));
}
#[test]
fn test_required_approvers() {
let policy = ApprovalPolicy::new("multi_approval")
.with_required_approver("security")
.with_required_approver("lead");
let partial = vec!["security".to_string()];
let complete = vec!["security".to_string(), "lead".to_string()];
assert!(!policy.all_required_approved(&partial));
assert!(policy.all_required_approved(&complete));
}
}