use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "lowercase")]
pub enum Decision {
Allow,
Monitor { rule_id: String },
Ask { rule_id: String, reason: String },
Deny { rule_id: String, reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum DecisionRank {
Allow,
Monitor,
Ask,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DecisionKind {
Allow,
Monitor,
Ask,
Deny,
}
impl Decision {
pub(crate) fn rank(&self) -> DecisionRank {
match self {
Self::Allow => DecisionRank::Allow,
Self::Monitor { .. } => DecisionRank::Monitor,
Self::Ask { .. } => DecisionRank::Ask,
Self::Deny { .. } => DecisionRank::Deny,
}
}
pub fn rule_id(&self) -> Option<&str> {
match self {
Self::Allow => None,
Self::Monitor { rule_id } | Self::Ask { rule_id, .. } | Self::Deny { rule_id, .. } => {
Some(rule_id.as_str())
},
}
}
pub fn reason(&self) -> Option<&str> {
match self {
Self::Ask { reason, .. } | Self::Deny { reason, .. } => Some(reason.as_str()),
Self::Allow | Self::Monitor { .. } => None,
}
}
pub fn kind(&self) -> DecisionKind {
match self {
Self::Allow => DecisionKind::Allow,
Self::Monitor { .. } => DecisionKind::Monitor,
Self::Ask { .. } => DecisionKind::Ask,
Self::Deny { .. } => DecisionKind::Deny,
}
}
}
pub fn aggregate<I>(decisions: I) -> Decision
where
I: IntoIterator<Item = Decision>,
{
decisions
.into_iter()
.max_by_key(Decision::rank)
.unwrap_or(Decision::Allow)
}
#[cfg(test)]
mod tests {
use super::*;
fn deny(id: &str) -> Decision {
Decision::Deny {
rule_id: id.into(),
reason: format!("blocked by {id}"),
}
}
fn ask(id: &str) -> Decision {
Decision::Ask {
rule_id: id.into(),
reason: format!("confirm for {id}"),
}
}
fn monitor(id: &str) -> Decision {
Decision::Monitor { rule_id: id.into() }
}
#[test]
fn decision_serialises_allow() {
let json = serde_json::to_string(&Decision::Allow).expect("serialise");
assert_eq!(json, "{\"decision\":\"allow\"}");
}
#[test]
fn decision_serialises_deny_with_rule_id_and_reason() {
let json = serde_json::to_string(&deny("core.test")).expect("serialise");
assert!(json.contains("\"decision\":\"deny\""));
assert!(json.contains("\"rule_id\":\"core.test\""));
assert!(json.contains("\"reason\":\"blocked by core.test\""));
}
#[test]
fn decision_serialises_monitor_and_ask() {
let m = serde_json::to_string(&monitor("core.m")).expect("serialise");
assert!(m.contains("\"decision\":\"monitor\""));
assert!(m.contains("\"rule_id\":\"core.m\""));
let a = serde_json::to_string(&ask("core.a")).expect("serialise");
assert!(a.contains("\"decision\":\"ask\""));
assert!(a.contains("\"rule_id\":\"core.a\""));
assert!(a.contains("\"reason\":\"confirm for core.a\""));
}
#[test]
fn decision_round_trips_through_json() {
for original in [
Decision::Allow,
monitor("core.m"),
ask("core.a"),
deny("core.d"),
] {
let encoded = serde_json::to_string(&original).expect("encode");
let decoded: Decision = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, original);
}
}
#[test]
fn severity_is_monotonic() {
assert!(Decision::Allow.rank() < monitor("x").rank());
assert!(monitor("x").rank() < ask("x").rank());
assert!(ask("x").rank() < deny("x").rank());
}
#[test]
fn rule_id_and_reason_accessors() {
assert_eq!(Decision::Allow.rule_id(), None);
assert_eq!(Decision::Allow.reason(), None);
assert_eq!(monitor("a.b").rule_id(), Some("a.b"));
assert_eq!(monitor("a.b").reason(), None);
assert_eq!(ask("a.b").rule_id(), Some("a.b"));
assert_eq!(ask("a.b").reason(), Some("confirm for a.b"));
assert_eq!(deny("a.b").rule_id(), Some("a.b"));
assert_eq!(deny("a.b").reason(), Some("blocked by a.b"));
}
#[test]
fn aggregate_empty_is_allow() {
let empty: Vec<Decision> = Vec::new();
assert_eq!(aggregate(empty), Decision::Allow);
}
#[test]
fn severity_enum_orders_info_below_critical() {
assert!(Severity::Info < Severity::Low);
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
assert!(Severity::High < Severity::Critical);
}
#[test]
fn severity_serialises_lowercase() {
let json = serde_json::to_string(&Severity::Critical).expect("serialise");
assert_eq!(json, "\"critical\"");
let parsed: Severity = serde_json::from_str("\"info\"").expect("parse");
assert_eq!(parsed, Severity::Info);
}
#[test]
fn decision_kind_serialises_lowercase() {
assert_eq!(
serde_json::to_string(&DecisionKind::Deny).expect("serialise"),
"\"deny\"",
);
let parsed: DecisionKind = serde_json::from_str("\"monitor\"").expect("parse");
assert_eq!(parsed, DecisionKind::Monitor);
}
#[test]
fn decision_kind_matches_variant() {
assert_eq!(Decision::Allow.kind(), DecisionKind::Allow);
assert_eq!(monitor("x").kind(), DecisionKind::Monitor);
assert_eq!(ask("x").kind(), DecisionKind::Ask);
assert_eq!(deny("x").kind(), DecisionKind::Deny);
}
#[test]
fn aggregate_picks_most_restrictive() {
let result = aggregate([Decision::Allow, monitor("m"), deny("d"), ask("a")]);
assert_eq!(result, deny("d"));
let result = aggregate([Decision::Allow, monitor("m"), ask("a")]);
assert_eq!(result, ask("a"));
let result = aggregate([Decision::Allow, monitor("m")]);
assert_eq!(result, monitor("m"));
let result = aggregate([Decision::Allow, Decision::Allow]);
assert_eq!(result, Decision::Allow);
}
use crate::testing::proptest::{decision, decision_kind, decision_list, severity};
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_aggregate_empty_is_allow(_dummy in 0u8..1) {
prop_assert_eq!(aggregate(Vec::<Decision>::new()), Decision::Allow);
}
#[test]
fn pbt_aggregate_singleton_is_identity(d in decision()) {
prop_assert_eq!(aggregate([d.clone()]), d);
}
#[test]
fn pbt_aggregate_is_idempotent(d in decision(), n in 1usize..6) {
let xs: Vec<Decision> = std::iter::repeat_n(d.clone(), n).collect();
prop_assert_eq!(aggregate(xs), d);
}
#[test]
fn pbt_aggregate_is_associative(xs in decision_list(), ys in decision_list()) {
let combined: Vec<Decision> = xs.iter().chain(ys.iter()).cloned().collect();
let split = aggregate([aggregate(xs.clone()), aggregate(ys.clone())]);
prop_assert_eq!(aggregate(combined), split);
}
#[test]
fn pbt_aggregate_is_severity_invariant_under_permutation(
xs in decision_list(),
) {
let mut reversed = xs.clone();
reversed.reverse();
prop_assert_eq!(aggregate(xs).rank(), aggregate(reversed).rank());
}
#[test]
fn pbt_aggregate_is_upper_bound(xs in decision_list()) {
let agg = aggregate(xs.clone());
for x in &xs {
prop_assert!(agg.rank() >= x.rank());
}
}
#[test]
fn pbt_severity_matches_kind_order(d in decision()) {
let expected = match d.kind() {
DecisionKind::Allow => DecisionRank::Allow,
DecisionKind::Monitor => DecisionRank::Monitor,
DecisionKind::Ask => DecisionRank::Ask,
DecisionKind::Deny => DecisionRank::Deny,
};
prop_assert_eq!(d.rank(), expected);
}
#[test]
fn pbt_decision_round_trips_through_json(d in decision()) {
let s = serde_json::to_string(&d).expect("serialise");
let back: Decision = serde_json::from_str(&s).expect("deserialise");
prop_assert_eq!(back, d);
}
#[test]
fn pbt_severity_round_trips(s in severity()) {
let json = serde_json::to_string(&s).expect("serialise");
let back: Severity = serde_json::from_str(&json).expect("deserialise");
prop_assert_eq!(back, s);
}
#[test]
fn pbt_decision_kind_round_trips(k in decision_kind()) {
let json = serde_json::to_string(&k).expect("serialise");
let back: DecisionKind = serde_json::from_str(&json).expect("deserialise");
prop_assert_eq!(back, k);
}
#[test]
fn pbt_kind_matches_variant(d in decision()) {
let expected = match &d {
Decision::Allow => DecisionKind::Allow,
Decision::Monitor { .. } => DecisionKind::Monitor,
Decision::Ask { .. } => DecisionKind::Ask,
Decision::Deny { .. } => DecisionKind::Deny,
};
prop_assert_eq!(d.kind(), expected);
}
}
}