Skip to main content

auths_policy/
decision.rs

1//! Authorization decision types.
2//!
3//! Decisions carry structured evidence, not just reason strings. Every decision
4//! includes a machine-readable reason code for stable logging and alerting.
5
6use serde::{Deserialize, Serialize};
7
8/// Three-valued authorization decision.
9///
10/// Contains the outcome, a machine-readable reason code, a human-readable message,
11/// and an optional policy hash for audit pinning.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Decision {
14    /// The authorization outcome.
15    pub outcome: Outcome,
16    /// Machine-readable reason code for logging and alerting.
17    pub reason: ReasonCode,
18    /// Human-readable explanation of the decision.
19    pub message: String,
20    /// Blake3 hash of the policy that produced this decision.
21    /// Used for audit pinning.
22    pub policy_hash: Option<[u8; 32]>,
23}
24
25/// The outcome of a policy evaluation.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum Outcome {
29    /// The action is allowed.
30    Allow,
31    /// The action is denied.
32    Deny,
33    /// The decision could not be made due to missing information.
34    /// In strict mode, this is treated as Deny.
35    Indeterminate,
36    /// The action requires human approval before proceeding.
37    /// Propagated through `evaluate_strict` (NOT collapsed to Deny).
38    RequiresApproval,
39    /// No attestation was provided. Distinct from Deny (attestation was invalid).
40    MissingCredential,
41}
42
43/// Machine-readable reason code for stable logging and alerting.
44///
45/// These codes are designed to be stable across versions for use in
46/// monitoring dashboards, alerting rules, and audit queries.
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[non_exhaustive]
49pub enum ReasonCode {
50    /// Unconditional allow/deny (True/False expressions).
51    Unconditional,
52    /// All checks in a policy passed.
53    AllChecksPassed,
54    /// Required capability is present.
55    CapabilityPresent,
56    /// Required capability is missing.
57    CapabilityMissing,
58    /// Issuer matches expected value.
59    IssuerMatch,
60    /// Issuer does not match expected value.
61    IssuerMismatch,
62    /// Attestation has been revoked.
63    Revoked,
64    /// Attestation has expired.
65    Expired,
66    /// Remaining TTL is below required threshold.
67    InsufficientTtl,
68    /// Attestation was issued too long ago.
69    IssuedTooLongAgo,
70    /// Role does not match expected value.
71    RoleMismatch,
72    /// Scope (repo, ref, path, env) does not match.
73    ScopeMismatch,
74    /// Delegation chain exceeds maximum depth.
75    ChainTooDeep,
76    /// Delegator does not match expected value.
77    DelegationMismatch,
78    /// Custom attribute does not match expected value.
79    AttrMismatch,
80    /// Required field is missing from context.
81    MissingField,
82    /// Expression recursion limit exceeded.
83    RecursionExceeded,
84    /// Short-circuit evaluation in And/Or.
85    ShortCircuit,
86    /// Result from And/Or/Not combinator.
87    CombinatorResult,
88    /// Workload claim does not match expected value.
89    WorkloadMismatch,
90    /// Witness quorum was not met.
91    WitnessQuorumNotMet,
92    /// Signer type matches expected value.
93    SignerTypeMatch,
94    /// Signer type does not match expected value.
95    SignerTypeMismatch,
96    /// Policy ApprovalGate determined human approval is needed.
97    ApprovalRequired,
98    /// Approval attestation was valid and matched.
99    ApprovalGranted,
100    /// Approval request TTL expired.
101    ApprovalExpired,
102    /// Approval JTI already used (replay attempt).
103    ApprovalAlreadyUsed,
104    /// Approval scope hash doesn't match the current request.
105    ApprovalRequestMismatch,
106    /// Assurance level meets or exceeds the minimum requirement.
107    AssuranceMet,
108    /// Assurance level is below the minimum requirement.
109    AssuranceInsufficient,
110}
111
112impl Decision {
113    /// Create an Allow decision with the given reason and message.
114    pub fn allow(reason: ReasonCode, message: impl Into<String>) -> Self {
115        Self {
116            outcome: Outcome::Allow,
117            reason,
118            message: message.into(),
119            policy_hash: None,
120        }
121    }
122
123    /// Create a Deny decision with the given reason and message.
124    pub fn deny(reason: ReasonCode, message: impl Into<String>) -> Self {
125        Self {
126            outcome: Outcome::Deny,
127            reason,
128            message: message.into(),
129            policy_hash: None,
130        }
131    }
132
133    /// Create an Indeterminate decision with the given reason and message.
134    pub fn indeterminate(reason: ReasonCode, message: impl Into<String>) -> Self {
135        Self {
136            outcome: Outcome::Indeterminate,
137            reason,
138            message: message.into(),
139            policy_hash: None,
140        }
141    }
142
143    /// Create a RequiresApproval decision with the given reason and message.
144    pub fn requires_approval(reason: ReasonCode, message: impl Into<String>) -> Self {
145        Self {
146            outcome: Outcome::RequiresApproval,
147            reason,
148            message: message.into(),
149            policy_hash: None,
150        }
151    }
152
153    /// Attach a policy hash to this decision for audit pinning.
154    pub fn with_policy_hash(mut self, hash: [u8; 32]) -> Self {
155        self.policy_hash = Some(hash);
156        self
157    }
158
159    /// Returns true if the outcome is Allow.
160    pub fn is_allowed(&self) -> bool {
161        self.outcome == Outcome::Allow
162    }
163
164    /// Returns true if the outcome is Deny.
165    pub fn is_denied(&self) -> bool {
166        self.outcome == Outcome::Deny
167    }
168
169    /// Returns true if the outcome is Indeterminate.
170    pub fn is_indeterminate(&self) -> bool {
171        self.outcome == Outcome::Indeterminate
172    }
173
174    /// Returns true if the outcome is RequiresApproval.
175    pub fn is_approval_required(&self) -> bool {
176        self.outcome == Outcome::RequiresApproval
177    }
178}
179
180impl std::fmt::Display for Outcome {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            Outcome::Allow => write!(f, "ALLOW"),
184            Outcome::Deny => write!(f, "DENY"),
185            Outcome::Indeterminate => write!(f, "INDETERMINATE"),
186            Outcome::RequiresApproval => write!(f, "REQUIRES_APPROVAL"),
187            Outcome::MissingCredential => write!(f, "MISSING_CREDENTIAL"),
188        }
189    }
190}
191
192impl std::fmt::Display for ReasonCode {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        write!(f, "{:?}", self)
195    }
196}
197
198impl std::fmt::Display for Decision {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        write!(f, "{} ({}): {}", self.outcome, self.reason, self.message)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn allow_decision() {
210        let d = Decision::allow(ReasonCode::CapabilityPresent, "has 'sign_commit'");
211        assert!(d.is_allowed());
212        assert!(!d.is_denied());
213        assert!(!d.is_indeterminate());
214        assert_eq!(d.outcome, Outcome::Allow);
215        assert_eq!(d.reason, ReasonCode::CapabilityPresent);
216        assert!(d.policy_hash.is_none());
217    }
218
219    #[test]
220    fn deny_decision() {
221        let d = Decision::deny(ReasonCode::Revoked, "attestation revoked");
222        assert!(!d.is_allowed());
223        assert!(d.is_denied());
224        assert!(!d.is_indeterminate());
225        assert_eq!(d.outcome, Outcome::Deny);
226    }
227
228    #[test]
229    fn indeterminate_decision() {
230        let d = Decision::indeterminate(ReasonCode::MissingField, "no repo in context");
231        assert!(!d.is_allowed());
232        assert!(!d.is_denied());
233        assert!(d.is_indeterminate());
234        assert_eq!(d.outcome, Outcome::Indeterminate);
235    }
236
237    #[test]
238    fn with_policy_hash() {
239        let hash = [0u8; 32];
240        let d = Decision::allow(ReasonCode::AllChecksPassed, "ok").with_policy_hash(hash);
241        assert_eq!(d.policy_hash, Some(hash));
242    }
243
244    #[test]
245    fn display_outcome() {
246        assert_eq!(Outcome::Allow.to_string(), "ALLOW");
247        assert_eq!(Outcome::Deny.to_string(), "DENY");
248        assert_eq!(Outcome::Indeterminate.to_string(), "INDETERMINATE");
249    }
250
251    #[test]
252    fn display_decision() {
253        let d = Decision::allow(ReasonCode::CapabilityPresent, "has cap");
254        let s = d.to_string();
255        assert!(s.contains("ALLOW"));
256        assert!(s.contains("CapabilityPresent"));
257        assert!(s.contains("has cap"));
258    }
259
260    #[test]
261    fn serde_roundtrip() {
262        let d = Decision::deny(ReasonCode::Expired, "expired at 2024-01-01");
263        let json = serde_json::to_string(&d).unwrap();
264        let parsed: Decision = serde_json::from_str(&json).unwrap();
265        assert_eq!(d, parsed);
266    }
267
268    #[test]
269    fn serde_with_hash() {
270        let hash = [1u8; 32];
271        let d = Decision::allow(ReasonCode::AllChecksPassed, "ok").with_policy_hash(hash);
272        let json = serde_json::to_string(&d).unwrap();
273        let parsed: Decision = serde_json::from_str(&json).unwrap();
274        assert_eq!(d, parsed);
275        assert_eq!(parsed.policy_hash, Some(hash));
276    }
277}