1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct ErrorDetail {
11 pub code: String,
14 pub decision: String,
16 pub rule_id: String,
18 pub reason: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Decision {
25 Allow {
27 matched_rule: String,
29 },
30
31 Deny {
33 reason: String,
35 matched_rule: String,
37 },
38
39 RequireApproval {
41 reason: String,
43 matched_rule: String,
45 },
46}
47
48impl Decision {
49 pub fn is_allowed(&self) -> bool {
51 matches!(self, Decision::Allow { .. })
52 }
53
54 pub fn is_denied(&self) -> bool {
56 matches!(self, Decision::Deny { .. })
57 }
58
59 pub fn requires_approval(&self) -> bool {
61 matches!(self, Decision::RequireApproval { .. })
62 }
63
64 pub fn matched_rule(&self) -> &str {
66 match self {
67 Decision::Allow { matched_rule }
68 | Decision::Deny { matched_rule, .. }
69 | Decision::RequireApproval { matched_rule, .. } => matched_rule,
70 }
71 }
72
73 pub fn reason(&self) -> Option<&str> {
75 match self {
76 Decision::Allow { .. } => None,
77 Decision::Deny { reason, .. } | Decision::RequireApproval { reason, .. } => {
78 Some(reason)
79 }
80 }
81 }
82
83 pub fn decision_type(&self) -> &'static str {
85 match self {
86 Decision::Allow { .. } => "allow",
87 Decision::Deny { .. } => "deny",
88 Decision::RequireApproval { .. } => "require_approval",
89 }
90 }
91
92 pub fn error_detail(&self) -> Option<ErrorDetail> {
96 match self {
97 Decision::Allow { .. } => None,
98 Decision::Deny {
99 reason,
100 matched_rule,
101 } => {
102 let code = if matched_rule == "_default_deny" {
103 "POLICY_DEFAULT_DENY"
104 } else {
105 "POLICY_DENY"
106 };
107 Some(ErrorDetail {
108 code: code.into(),
109 decision: "deny".into(),
110 rule_id: matched_rule.clone(),
111 reason: reason.clone(),
112 })
113 }
114 Decision::RequireApproval {
115 reason,
116 matched_rule,
117 } => Some(ErrorDetail {
118 code: "POLICY_APPROVAL_REQUIRED".into(),
119 decision: "require_approval".into(),
120 rule_id: matched_rule.clone(),
121 reason: reason.clone(),
122 }),
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn test_decision_allow() {
133 let decision = Decision::Allow {
134 matched_rule: "default-allow".into(),
135 };
136 assert!(decision.is_allowed());
137 assert!(!decision.is_denied());
138 assert!(!decision.requires_approval());
139 }
140
141 #[test]
142 fn test_decision_deny() {
143 let decision = Decision::Deny {
144 reason: "blocked by policy".into(),
145 matched_rule: "no-file-delete".into(),
146 };
147 assert!(!decision.is_allowed());
148 assert!(decision.is_denied());
149 assert!(!decision.requires_approval());
150 }
151
152 #[test]
153 fn test_decision_require_approval() {
154 let decision = Decision::RequireApproval {
155 reason: "sensitive operation".into(),
156 matched_rule: "approve-email-send".into(),
157 };
158 assert!(!decision.is_allowed());
159 assert!(!decision.is_denied());
160 assert!(decision.requires_approval());
161 }
162
163 #[test]
164 fn test_decision_accessors() {
165 let allow = Decision::Allow {
166 matched_rule: "allow-read".into(),
167 };
168 assert_eq!(allow.matched_rule(), "allow-read");
169 assert_eq!(allow.reason(), None);
170 assert_eq!(allow.decision_type(), "allow");
171
172 let deny = Decision::Deny {
173 reason: "not permitted".into(),
174 matched_rule: "deny-shell".into(),
175 };
176 assert_eq!(deny.matched_rule(), "deny-shell");
177 assert_eq!(deny.reason(), Some("not permitted"));
178 assert_eq!(deny.decision_type(), "deny");
179
180 let approval = Decision::RequireApproval {
181 reason: "needs approval".into(),
182 matched_rule: "approve-email".into(),
183 };
184 assert_eq!(approval.matched_rule(), "approve-email");
185 assert_eq!(approval.reason(), Some("needs approval"));
186 assert_eq!(approval.decision_type(), "require_approval");
187 }
188
189 #[test]
190 fn test_error_detail_deny() {
191 let decision = Decision::Deny {
192 reason: "destructive operation".into(),
193 matched_rule: "deny-drop-table".into(),
194 };
195 let detail = decision.error_detail().unwrap();
196 assert_eq!(detail.code, "POLICY_DENY");
197 assert_eq!(detail.decision, "deny");
198 assert_eq!(detail.rule_id, "deny-drop-table");
199 assert_eq!(detail.reason, "destructive operation");
200 }
201
202 #[test]
203 fn test_error_detail_default_deny() {
204 let decision = Decision::Deny {
205 reason: "no matching policy rule — denied by default (fail-closed)".into(),
206 matched_rule: "_default_deny".into(),
207 };
208 let detail = decision.error_detail().unwrap();
209 assert_eq!(detail.code, "POLICY_DEFAULT_DENY");
210 assert_eq!(detail.rule_id, "_default_deny");
211 }
212
213 #[test]
214 fn test_error_detail_require_approval() {
215 let decision = Decision::RequireApproval {
216 reason: "email send needs approval".into(),
217 matched_rule: "approve-email".into(),
218 };
219 let detail = decision.error_detail().unwrap();
220 assert_eq!(detail.code, "POLICY_APPROVAL_REQUIRED");
221 assert_eq!(detail.decision, "require_approval");
222 assert_eq!(detail.rule_id, "approve-email");
223 assert_eq!(detail.reason, "email send needs approval");
224 }
225
226 #[test]
227 fn test_error_detail_allow_returns_none() {
228 let decision = Decision::Allow {
229 matched_rule: "allow-read".into(),
230 };
231 assert!(decision.error_detail().is_none());
232 }
233
234 #[test]
235 fn test_error_detail_serialization() {
236 let detail = ErrorDetail {
237 code: "POLICY_DENY".into(),
238 decision: "deny".into(),
239 rule_id: "deny-shell".into(),
240 reason: "shell not allowed".into(),
241 };
242 let json = serde_json::to_value(&detail).unwrap();
243 assert_eq!(json["code"], "POLICY_DENY");
244 assert_eq!(json["decision"], "deny");
245 assert_eq!(json["rule_id"], "deny-shell");
246 assert_eq!(json["reason"], "shell not allowed");
247
248 let back: ErrorDetail = serde_json::from_value(json).unwrap();
250 assert_eq!(back, detail);
251 }
252}