1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Decision {
14 pub outcome: Outcome,
16 pub reason: ReasonCode,
18 pub message: String,
20 pub policy_hash: Option<[u8; 32]>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum Outcome {
29 Allow,
31 Deny,
33 Indeterminate,
36 RequiresApproval,
39 MissingCredential,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[non_exhaustive]
49pub enum ReasonCode {
50 Unconditional,
52 AllChecksPassed,
54 CapabilityPresent,
56 CapabilityMissing,
58 IssuerMatch,
60 IssuerMismatch,
62 Revoked,
64 Expired,
66 InsufficientTtl,
68 IssuedTooLongAgo,
70 RoleMismatch,
72 ScopeMismatch,
74 ChainTooDeep,
76 DelegationMismatch,
78 AttrMismatch,
80 MissingField,
82 RecursionExceeded,
84 ShortCircuit,
86 CombinatorResult,
88 WorkloadMismatch,
90 WitnessQuorumNotMet,
92 SignerTypeMatch,
94 SignerTypeMismatch,
96 ApprovalRequired,
98 ApprovalGranted,
100 ApprovalExpired,
102 ApprovalAlreadyUsed,
104 ApprovalRequestMismatch,
106 AssuranceMet,
108 AssuranceInsufficient,
110}
111
112impl Decision {
113 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 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 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 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 pub fn with_policy_hash(mut self, hash: [u8; 32]) -> Self {
155 self.policy_hash = Some(hash);
156 self
157 }
158
159 pub fn is_allowed(&self) -> bool {
161 self.outcome == Outcome::Allow
162 }
163
164 pub fn is_denied(&self) -> bool {
166 self.outcome == Outcome::Deny
167 }
168
169 pub fn is_indeterminate(&self) -> bool {
171 self.outcome == Outcome::Indeterminate
172 }
173
174 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}