use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorDetail {
pub code: String,
pub decision: String,
pub rule_id: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Decision {
Allow {
matched_rule: String,
},
Deny {
reason: String,
matched_rule: String,
},
RequireApproval {
reason: String,
matched_rule: String,
},
}
impl Decision {
pub fn is_allowed(&self) -> bool {
matches!(self, Decision::Allow { .. })
}
pub fn is_denied(&self) -> bool {
matches!(self, Decision::Deny { .. })
}
pub fn requires_approval(&self) -> bool {
matches!(self, Decision::RequireApproval { .. })
}
pub fn matched_rule(&self) -> &str {
match self {
Decision::Allow { matched_rule }
| Decision::Deny { matched_rule, .. }
| Decision::RequireApproval { matched_rule, .. } => matched_rule,
}
}
pub fn reason(&self) -> Option<&str> {
match self {
Decision::Allow { .. } => None,
Decision::Deny { reason, .. } | Decision::RequireApproval { reason, .. } => {
Some(reason)
}
}
}
pub fn decision_type(&self) -> &'static str {
match self {
Decision::Allow { .. } => "allow",
Decision::Deny { .. } => "deny",
Decision::RequireApproval { .. } => "require_approval",
}
}
pub fn error_detail(&self) -> Option<ErrorDetail> {
match self {
Decision::Allow { .. } => None,
Decision::Deny {
reason,
matched_rule,
} => {
let code = if matched_rule == "_default_deny" {
"POLICY_DEFAULT_DENY"
} else {
"POLICY_DENY"
};
Some(ErrorDetail {
code: code.into(),
decision: "deny".into(),
rule_id: matched_rule.clone(),
reason: reason.clone(),
})
}
Decision::RequireApproval {
reason,
matched_rule,
} => Some(ErrorDetail {
code: "POLICY_APPROVAL_REQUIRED".into(),
decision: "require_approval".into(),
rule_id: matched_rule.clone(),
reason: reason.clone(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decision_allow() {
let decision = Decision::Allow {
matched_rule: "default-allow".into(),
};
assert!(decision.is_allowed());
assert!(!decision.is_denied());
assert!(!decision.requires_approval());
}
#[test]
fn test_decision_deny() {
let decision = Decision::Deny {
reason: "blocked by policy".into(),
matched_rule: "no-file-delete".into(),
};
assert!(!decision.is_allowed());
assert!(decision.is_denied());
assert!(!decision.requires_approval());
}
#[test]
fn test_decision_require_approval() {
let decision = Decision::RequireApproval {
reason: "sensitive operation".into(),
matched_rule: "approve-email-send".into(),
};
assert!(!decision.is_allowed());
assert!(!decision.is_denied());
assert!(decision.requires_approval());
}
#[test]
fn test_decision_accessors() {
let allow = Decision::Allow {
matched_rule: "allow-read".into(),
};
assert_eq!(allow.matched_rule(), "allow-read");
assert_eq!(allow.reason(), None);
assert_eq!(allow.decision_type(), "allow");
let deny = Decision::Deny {
reason: "not permitted".into(),
matched_rule: "deny-shell".into(),
};
assert_eq!(deny.matched_rule(), "deny-shell");
assert_eq!(deny.reason(), Some("not permitted"));
assert_eq!(deny.decision_type(), "deny");
let approval = Decision::RequireApproval {
reason: "needs approval".into(),
matched_rule: "approve-email".into(),
};
assert_eq!(approval.matched_rule(), "approve-email");
assert_eq!(approval.reason(), Some("needs approval"));
assert_eq!(approval.decision_type(), "require_approval");
}
#[test]
fn test_error_detail_deny() {
let decision = Decision::Deny {
reason: "destructive operation".into(),
matched_rule: "deny-drop-table".into(),
};
let detail = decision.error_detail().unwrap();
assert_eq!(detail.code, "POLICY_DENY");
assert_eq!(detail.decision, "deny");
assert_eq!(detail.rule_id, "deny-drop-table");
assert_eq!(detail.reason, "destructive operation");
}
#[test]
fn test_error_detail_default_deny() {
let decision = Decision::Deny {
reason: "no matching policy rule — denied by default (fail-closed)".into(),
matched_rule: "_default_deny".into(),
};
let detail = decision.error_detail().unwrap();
assert_eq!(detail.code, "POLICY_DEFAULT_DENY");
assert_eq!(detail.rule_id, "_default_deny");
}
#[test]
fn test_error_detail_require_approval() {
let decision = Decision::RequireApproval {
reason: "email send needs approval".into(),
matched_rule: "approve-email".into(),
};
let detail = decision.error_detail().unwrap();
assert_eq!(detail.code, "POLICY_APPROVAL_REQUIRED");
assert_eq!(detail.decision, "require_approval");
assert_eq!(detail.rule_id, "approve-email");
assert_eq!(detail.reason, "email send needs approval");
}
#[test]
fn test_error_detail_allow_returns_none() {
let decision = Decision::Allow {
matched_rule: "allow-read".into(),
};
assert!(decision.error_detail().is_none());
}
#[test]
fn test_error_detail_serialization() {
let detail = ErrorDetail {
code: "POLICY_DENY".into(),
decision: "deny".into(),
rule_id: "deny-shell".into(),
reason: "shell not allowed".into(),
};
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["code"], "POLICY_DENY");
assert_eq!(json["decision"], "deny");
assert_eq!(json["rule_id"], "deny-shell");
assert_eq!(json["reason"], "shell not allowed");
let back: ErrorDetail = serde_json::from_value(json).unwrap();
assert_eq!(back, detail);
}
}