use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub outcome: Outcome,
pub reason: ReasonCode,
pub message: String,
pub policy_hash: Option<[u8; 32]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Outcome {
Allow,
Deny,
Indeterminate,
RequiresApproval,
MissingCredential,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ReasonCode {
Unconditional,
AllChecksPassed,
CapabilityPresent,
CapabilityMissing,
IssuerMatch,
IssuerMismatch,
Revoked,
Expired,
InsufficientTtl,
IssuedTooLongAgo,
RoleMismatch,
ScopeMismatch,
ChainTooDeep,
DelegationMismatch,
AttrMismatch,
MissingField,
RecursionExceeded,
ShortCircuit,
CombinatorResult,
WorkloadMismatch,
WitnessQuorumNotMet,
SignerTypeMatch,
SignerTypeMismatch,
ApprovalRequired,
ApprovalGranted,
ApprovalExpired,
ApprovalAlreadyUsed,
ApprovalRequestMismatch,
AssuranceMet,
AssuranceInsufficient,
}
impl Decision {
pub fn allow(reason: ReasonCode, message: impl Into<String>) -> Self {
Self {
outcome: Outcome::Allow,
reason,
message: message.into(),
policy_hash: None,
}
}
pub fn deny(reason: ReasonCode, message: impl Into<String>) -> Self {
Self {
outcome: Outcome::Deny,
reason,
message: message.into(),
policy_hash: None,
}
}
pub fn indeterminate(reason: ReasonCode, message: impl Into<String>) -> Self {
Self {
outcome: Outcome::Indeterminate,
reason,
message: message.into(),
policy_hash: None,
}
}
pub fn requires_approval(reason: ReasonCode, message: impl Into<String>) -> Self {
Self {
outcome: Outcome::RequiresApproval,
reason,
message: message.into(),
policy_hash: None,
}
}
pub fn with_policy_hash(mut self, hash: [u8; 32]) -> Self {
self.policy_hash = Some(hash);
self
}
pub fn is_allowed(&self) -> bool {
self.outcome == Outcome::Allow
}
pub fn is_denied(&self) -> bool {
self.outcome == Outcome::Deny
}
pub fn is_indeterminate(&self) -> bool {
self.outcome == Outcome::Indeterminate
}
pub fn is_approval_required(&self) -> bool {
self.outcome == Outcome::RequiresApproval
}
}
impl std::fmt::Display for Outcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Outcome::Allow => write!(f, "ALLOW"),
Outcome::Deny => write!(f, "DENY"),
Outcome::Indeterminate => write!(f, "INDETERMINATE"),
Outcome::RequiresApproval => write!(f, "REQUIRES_APPROVAL"),
Outcome::MissingCredential => write!(f, "MISSING_CREDENTIAL"),
}
}
}
impl std::fmt::Display for ReasonCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::fmt::Display for Decision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({}): {}", self.outcome, self.reason, self.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allow_decision() {
let d = Decision::allow(ReasonCode::CapabilityPresent, "has 'sign_commit'");
assert!(d.is_allowed());
assert!(!d.is_denied());
assert!(!d.is_indeterminate());
assert_eq!(d.outcome, Outcome::Allow);
assert_eq!(d.reason, ReasonCode::CapabilityPresent);
assert!(d.policy_hash.is_none());
}
#[test]
fn deny_decision() {
let d = Decision::deny(ReasonCode::Revoked, "attestation revoked");
assert!(!d.is_allowed());
assert!(d.is_denied());
assert!(!d.is_indeterminate());
assert_eq!(d.outcome, Outcome::Deny);
}
#[test]
fn indeterminate_decision() {
let d = Decision::indeterminate(ReasonCode::MissingField, "no repo in context");
assert!(!d.is_allowed());
assert!(!d.is_denied());
assert!(d.is_indeterminate());
assert_eq!(d.outcome, Outcome::Indeterminate);
}
#[test]
fn with_policy_hash() {
let hash = [0u8; 32];
let d = Decision::allow(ReasonCode::AllChecksPassed, "ok").with_policy_hash(hash);
assert_eq!(d.policy_hash, Some(hash));
}
#[test]
fn display_outcome() {
assert_eq!(Outcome::Allow.to_string(), "ALLOW");
assert_eq!(Outcome::Deny.to_string(), "DENY");
assert_eq!(Outcome::Indeterminate.to_string(), "INDETERMINATE");
}
#[test]
fn display_decision() {
let d = Decision::allow(ReasonCode::CapabilityPresent, "has cap");
let s = d.to_string();
assert!(s.contains("ALLOW"));
assert!(s.contains("CapabilityPresent"));
assert!(s.contains("has cap"));
}
#[test]
fn serde_roundtrip() {
let d = Decision::deny(ReasonCode::Expired, "expired at 2024-01-01");
let json = serde_json::to_string(&d).unwrap();
let parsed: Decision = serde_json::from_str(&json).unwrap();
assert_eq!(d, parsed);
}
#[test]
fn serde_with_hash() {
let hash = [1u8; 32];
let d = Decision::allow(ReasonCode::AllChecksPassed, "ok").with_policy_hash(hash);
let json = serde_json::to_string(&d).unwrap();
let parsed: Decision = serde_json::from_str(&json).unwrap();
assert_eq!(d, parsed);
assert_eq!(parsed.policy_hash, Some(hash));
}
}