Skip to main content

kvlar_core/
decision.rs

1//! Decision types — the output of policy evaluation.
2
3use serde::{Deserialize, Serialize};
4
5/// Machine-readable error detail for denied or approval-required decisions.
6///
7/// Provides structured metadata that programmatic consumers can parse
8/// without scraping human-readable text messages.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct ErrorDetail {
11    /// Error code identifying the type of policy decision.
12    /// Values: `"POLICY_DENY"`, `"POLICY_APPROVAL_REQUIRED"`, `"POLICY_DEFAULT_DENY"`.
13    pub code: String,
14    /// The decision type: `"deny"` or `"require_approval"`.
15    pub decision: String,
16    /// The ID of the policy rule that matched.
17    pub rule_id: String,
18    /// Human-readable reason for the decision.
19    pub reason: String,
20}
21
22/// The result of evaluating an action against a policy.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Decision {
25    /// The action is allowed to proceed.
26    Allow {
27        /// Which policy rule matched to allow this action.
28        matched_rule: String,
29    },
30
31    /// The action is denied.
32    Deny {
33        /// The reason for denial.
34        reason: String,
35        /// Which policy rule matched to deny this action.
36        matched_rule: String,
37    },
38
39    /// The action requires human approval before proceeding.
40    RequireApproval {
41        /// Why approval is needed.
42        reason: String,
43        /// Which policy rule triggered the approval requirement.
44        matched_rule: String,
45    },
46}
47
48impl Decision {
49    /// Returns true if this decision allows the action.
50    pub fn is_allowed(&self) -> bool {
51        matches!(self, Decision::Allow { .. })
52    }
53
54    /// Returns true if this decision denies the action.
55    pub fn is_denied(&self) -> bool {
56        matches!(self, Decision::Deny { .. })
57    }
58
59    /// Returns true if this decision requires human approval.
60    pub fn requires_approval(&self) -> bool {
61        matches!(self, Decision::RequireApproval { .. })
62    }
63
64    /// Returns the ID of the matched policy rule.
65    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    /// Returns the reason string, if any (Deny and RequireApproval have reasons).
74    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    /// Returns the decision type as a string: "allow", "deny", or "require_approval".
84    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    /// Returns structured error detail for denied or approval-required decisions.
93    ///
94    /// Returns `None` for allowed decisions (no error to report).
95    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        // Roundtrip
249        let back: ErrorDetail = serde_json::from_value(json).unwrap();
250        assert_eq!(back, detail);
251    }
252}