auths_core/policy/decision.rs
1//! Authorization decision types.
2//!
3//! # Decision vs TrustDecision
4//!
5//! This module provides [`Decision`] for **authorization** decisions:
6//! "Can this device/identity perform this action?"
7//!
8//! This is distinct from [`crate::trust::TrustDecision`] which handles
9//! **identity verification**: "Is this key who they claim to be?"
10//!
11//! | Concern | Type | Question |
12//! |---------|------|----------|
13//! | Identity | `TrustDecision` | Is this key trusted? (TOFU, pins, rotation) |
14//! | Authorization | `Decision` | Can this device do this action? (capabilities, expiry) |
15//!
16//! # Usage
17//!
18//! ```rust
19//! use auths_core::policy::Decision;
20//!
21//! // Example: device has required capability
22//! let decision = Decision::Allow {
23//! reason: "Device has sign_commit capability".into(),
24//! };
25//!
26//! // Example: attestation expired
27//! let decision = Decision::Deny {
28//! reason: "Attestation expired at 2024-01-01T00:00:00Z".into(),
29//! };
30//!
31//! // Example: cannot determine (missing data)
32//! let decision = Decision::Indeterminate {
33//! reason: "No attestation found for device".into(),
34//! };
35//! ```
36
37use std::fmt;
38
39use serde::{Deserialize, Serialize};
40
41/// Result of an authorization policy evaluation.
42///
43/// Three-valued logic allows distinguishing between:
44/// - Explicit allow (requirements met)
45/// - Explicit deny (requirements violated)
46/// - Cannot determine (missing information)
47///
48/// This is important for fail-safe behavior: `Indeterminate` should typically
49/// be treated as `Deny` unless the policy explicitly allows pass-through.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub enum Decision {
52 /// Authorization granted.
53 ///
54 /// All requirements were checked and met. The action may proceed.
55 Allow {
56 /// Human-readable explanation of why authorization was granted.
57 reason: String,
58 },
59
60 /// Authorization denied.
61 ///
62 /// A specific requirement was violated. The action must not proceed.
63 Deny {
64 /// Human-readable explanation of why authorization was denied.
65 reason: String,
66 },
67
68 /// Cannot determine authorization.
69 ///
70 /// Required information was missing or invalid. This is NOT the same
71 /// as `Deny` - it indicates the policy engine couldn't make a decision.
72 ///
73 /// Callers should typically treat this as `Deny` for fail-safe behavior.
74 Indeterminate {
75 /// Human-readable explanation of why a decision couldn't be made.
76 reason: String,
77 },
78}
79
80impl Decision {
81 /// Create an Allow decision with the given reason.
82 pub fn allow(reason: impl Into<String>) -> Self {
83 Self::Allow {
84 reason: reason.into(),
85 }
86 }
87
88 /// Create a Deny decision with the given reason.
89 pub fn deny(reason: impl Into<String>) -> Self {
90 Self::Deny {
91 reason: reason.into(),
92 }
93 }
94
95 /// Create an Indeterminate decision with the given reason.
96 pub fn indeterminate(reason: impl Into<String>) -> Self {
97 Self::Indeterminate {
98 reason: reason.into(),
99 }
100 }
101
102 /// Returns true if this is an Allow decision.
103 pub fn is_allowed(&self) -> bool {
104 matches!(self, Self::Allow { .. })
105 }
106
107 /// Returns true if this is a Deny decision.
108 pub fn is_denied(&self) -> bool {
109 matches!(self, Self::Deny { .. })
110 }
111
112 /// Returns true if this is an Indeterminate decision.
113 pub fn is_indeterminate(&self) -> bool {
114 matches!(self, Self::Indeterminate { .. })
115 }
116
117 /// Returns the reason string for this decision.
118 pub fn reason(&self) -> &str {
119 match self {
120 Self::Allow { reason } => reason,
121 Self::Deny { reason } => reason,
122 Self::Indeterminate { reason } => reason,
123 }
124 }
125
126 /// Treat Indeterminate as Deny for fail-safe behavior.
127 ///
128 /// This is the recommended way to convert a Decision to a boolean
129 /// in security-sensitive contexts.
130 pub fn is_allowed_fail_safe(&self) -> bool {
131 matches!(self, Self::Allow { .. })
132 }
133}
134
135impl fmt::Display for Decision {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 match self {
138 Self::Allow { reason } => write!(f, "ALLOW: {}", reason),
139 Self::Deny { reason } => write!(f, "DENY: {}", reason),
140 Self::Indeterminate { reason } => write!(f, "INDETERMINATE: {}", reason),
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn decision_allow_is_allowed() {
151 let d = Decision::allow("test reason");
152 assert!(d.is_allowed());
153 assert!(!d.is_denied());
154 assert!(!d.is_indeterminate());
155 assert_eq!(d.reason(), "test reason");
156 }
157
158 #[test]
159 fn decision_deny_is_denied() {
160 let d = Decision::deny("expired");
161 assert!(!d.is_allowed());
162 assert!(d.is_denied());
163 assert!(!d.is_indeterminate());
164 assert_eq!(d.reason(), "expired");
165 }
166
167 #[test]
168 fn decision_indeterminate() {
169 let d = Decision::indeterminate("missing attestation");
170 assert!(!d.is_allowed());
171 assert!(!d.is_denied());
172 assert!(d.is_indeterminate());
173 assert_eq!(d.reason(), "missing attestation");
174 }
175
176 #[test]
177 fn decision_fail_safe() {
178 assert!(Decision::allow("ok").is_allowed_fail_safe());
179 assert!(!Decision::deny("no").is_allowed_fail_safe());
180 // Indeterminate treated as deny for fail-safe
181 assert!(!Decision::indeterminate("unknown").is_allowed_fail_safe());
182 }
183
184 #[test]
185 fn decision_display() {
186 assert_eq!(Decision::allow("granted").to_string(), "ALLOW: granted");
187 assert_eq!(Decision::deny("revoked").to_string(), "DENY: revoked");
188 assert_eq!(
189 Decision::indeterminate("no data").to_string(),
190 "INDETERMINATE: no data"
191 );
192 }
193
194 #[test]
195 fn decision_serialization_roundtrip() {
196 let decisions = vec![
197 Decision::allow("test allow"),
198 Decision::deny("test deny"),
199 Decision::indeterminate("test indeterminate"),
200 ];
201
202 for original in decisions {
203 let json = serde_json::to_string(&original).unwrap();
204 let parsed: Decision = serde_json::from_str(&json).unwrap();
205 assert_eq!(original, parsed);
206 }
207 }
208
209 #[test]
210 fn decision_debug() {
211 let d = Decision::allow("test");
212 let debug_str = format!("{:?}", d);
213 assert!(debug_str.contains("Allow"));
214 assert!(debug_str.contains("test"));
215 }
216}