Skip to main content

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}