use std::collections::HashMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::selector::Selector;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Policy {
pub path: String,
pub version: u64,
pub situation: String,
pub situation_selector: Selector,
#[serde(default)]
pub allow: Vec<AuthorizedAction>,
#[serde(default)]
pub deny: Vec<AuthorizedAction>,
#[serde(default)]
pub require_approval: Vec<ApprovalRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub procedure: Option<Vec<ProcedureStep>>,
#[serde(default)]
pub triggers: Vec<String>,
#[serde(default)]
pub required_fields: Vec<String>,
#[serde(default)]
pub severity: Severity,
pub proposed_by: String,
pub proposed_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratified_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratified_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratification_reasoning: Option<String>,
pub active_from: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<PolicySignature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_evaluator: Option<ExternalEvaluatorRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExternalEvaluatorRef {
Rego { source: EvaluatorSource },
Cedar { source: EvaluatorSource },
Wasm { source: EvaluatorSource },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EvaluatorSource {
Inline { body: String },
FilePath { path: std::path::PathBuf },
CommitRef { path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "algorithm", rename_all = "snake_case")]
pub enum PolicySignature {
Ed25519 {
signer_key_id: String,
signature_hex: String,
},
}
impl Policy {
pub fn is_ratified(&self) -> bool {
self.ratified_by.is_some()
}
pub fn is_currently_active(&self, now: DateTime<Utc>) -> bool {
if !self.is_ratified() || self.active_from > now {
return false;
}
!matches!(self.expires_at, Some(exp) if exp <= now)
}
pub fn handle(&self) -> String {
format!("{}@{}", self.path, self.version)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuthorizedAction {
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
#[serde(default)]
pub preconditions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApprovalRule {
pub action: String,
pub approvers: Vec<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration_opt"
)]
pub timeout: Option<Duration>,
pub fallback: FallbackAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcedureStep {
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub if_previous_failed: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
#[default]
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FallbackAction {
Block,
PickAlternative { action: String },
LowestRiskAlternative,
KeepCurrentState,
DelegateTo { policy_path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChangeProposal {
pub action: String,
pub agent_id: String,
pub intent: String,
pub preferred_option: String,
#[serde(default)]
pub alternatives: Vec<String>,
#[serde(default)]
pub tokens: Vec<String>,
#[serde(default)]
pub attached_fields: HashMap<String, String>,
}
impl ChangeProposal {
pub fn new(
action: impl Into<String>,
agent_id: impl Into<String>,
intent: impl Into<String>,
preferred_option: impl Into<String>,
) -> Self {
Self {
action: action.into(),
agent_id: agent_id.into(),
intent: intent.into(),
preferred_option: preferred_option.into(),
alternatives: Vec::new(),
tokens: Vec::new(),
attached_fields: HashMap::new(),
}
}
pub fn with_tokens<I, S>(mut self, tokens: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.tokens = tokens.into_iter().map(Into::into).collect();
self
}
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.attached_fields.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Decision {
Allow {
matched_policy: String,
#[serde(default)]
preconditions: Vec<String>,
},
Deny {
matched_policy: String,
reason: String,
},
RequireApproval {
matched_policy: String,
approvers: Vec<String>,
#[serde(default, with = "duration_opt")]
timeout: Option<Duration>,
fallback: FallbackAction,
#[serde(default)]
approval_task_path: Option<String>,
},
NoPolicyMatch,
}
impl Decision {
pub fn kind(&self) -> &'static str {
match self {
Decision::Allow { .. } => "allow",
Decision::Deny { .. } => "deny",
Decision::RequireApproval { .. } => "require_approval",
Decision::NoPolicyMatch => "no_policy_match",
}
}
}
mod duration_opt {
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match d {
Some(d) => (d.as_millis() as u64).serialize(s),
None => Option::<u64>::None.serialize(s),
}
}
pub fn deserialize<'de, D>(d: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<u64>::deserialize(d)?;
Ok(opt.map(Duration::from_millis))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_roundtrips() {
for s in [
Severity::Low,
Severity::Medium,
Severity::High,
Severity::Critical,
] {
let j = serde_json::to_value(s).unwrap();
let back: Severity = serde_json::from_value(j).unwrap();
assert_eq!(s, back);
}
}
#[test]
fn fallback_action_all_variants_serialize() {
let variants = vec![
FallbackAction::Block,
FallbackAction::PickAlternative {
action: "apply_safe".into(),
},
FallbackAction::LowestRiskAlternative,
FallbackAction::KeepCurrentState,
FallbackAction::DelegateTo {
policy_path: "infra/fallback".into(),
},
];
for v in variants {
let json = serde_json::to_value(&v).unwrap();
assert!(json.get("kind").is_some(), "tagged enum should emit kind");
let back: FallbackAction = serde_json::from_value(json).unwrap();
assert_eq!(v, back);
}
}
#[test]
fn decision_roundtrips() {
let d = Decision::RequireApproval {
matched_policy: "infra/k8s/pod-failing@1".into(),
approvers: vec!["human".into()],
timeout: Some(Duration::from_secs(3600)),
fallback: FallbackAction::LowestRiskAlternative,
approval_task_path: None,
};
let j = serde_json::to_value(&d).unwrap();
let back: Decision = serde_json::from_value(j).unwrap();
assert_eq!(d, back);
}
#[test]
fn change_proposal_builder() {
let p = ChangeProposal::new("promote", "agent-1", "merge option C", "spec-7")
.with_tokens(["reindex", "downtime"])
.with_field("estimated_downtime", "5m");
assert_eq!(
p.tokens,
vec!["reindex".to_string(), "downtime".to_string()]
);
assert_eq!(p.attached_fields.get("estimated_downtime").unwrap(), "5m");
}
fn base_policy(path: &str) -> Policy {
use crate::selector::Selector;
Policy {
path: path.into(),
version: 1,
situation: "test".into(),
situation_selector: Selector::Always,
allow: vec![],
deny: vec![],
require_approval: vec![],
procedure: None,
triggers: vec![],
required_fields: vec![],
severity: Severity::default(),
proposed_by: "claude".into(),
proposed_at: Utc::now(),
ratified_by: None,
ratified_at: None,
ratification_reasoning: None,
active_from: Utc::now(),
expires_at: None,
supersedes: None,
signature: None,
tenant_id: None,
external_evaluator: None,
}
}
#[test]
fn is_ratified_true_when_ratified_by_set() {
let mut p = base_policy("a");
p.ratified_by = Some("alice".into());
assert!(p.is_ratified());
}
#[test]
fn is_ratified_false_when_none() {
assert!(!base_policy("a").is_ratified());
}
#[test]
fn is_currently_active_requires_ratification() {
let p = base_policy("a");
assert!(!p.is_currently_active(Utc::now()));
}
#[test]
fn is_currently_active_true_when_ratified_and_in_window() {
use chrono::Duration;
let mut p = base_policy("a");
p.ratified_by = Some("alice".into());
p.active_from = Utc::now() - Duration::hours(1);
assert!(p.is_currently_active(Utc::now()));
}
#[test]
fn is_currently_active_false_when_future_active_from() {
use chrono::Duration;
let mut p = base_policy("a");
p.ratified_by = Some("alice".into());
p.active_from = Utc::now() + Duration::hours(1);
assert!(!p.is_currently_active(Utc::now()));
}
#[test]
fn is_currently_active_false_when_expired() {
use chrono::Duration;
let mut p = base_policy("a");
p.ratified_by = Some("alice".into());
p.active_from = Utc::now() - Duration::hours(2);
p.expires_at = Some(Utc::now() - Duration::hours(1)); assert!(!p.is_currently_active(Utc::now()));
}
#[test]
fn is_currently_active_false_when_expires_at_equals_now() {
let now = Utc::now();
let mut p = base_policy("a");
p.ratified_by = Some("alice".into());
p.active_from = now;
p.expires_at = Some(now);
assert!(!p.is_currently_active(now));
}
#[test]
fn is_currently_active_true_just_before_expiry() {
use chrono::Duration;
let now = Utc::now();
let mut p = base_policy("a");
p.ratified_by = Some("alice".into());
p.active_from = now - Duration::hours(1);
p.expires_at = Some(now + Duration::seconds(1));
assert!(p.is_currently_active(now));
}
#[test]
fn policy_handle_format() {
let mut p = base_policy("infra/k8s/pod-failing");
p.version = 3;
assert_eq!(p.handle(), "infra/k8s/pod-failing@3");
}
#[test]
fn external_evaluator_ref_all_variants_roundtrip() {
use std::path::PathBuf;
let sources = vec![
EvaluatorSource::Inline {
body: "package p\ndefault allow = false".into(),
},
EvaluatorSource::FilePath {
path: PathBuf::from("/policies/deny.rego"),
},
EvaluatorSource::CommitRef {
path: "/_policy/rego/main".into(),
},
];
for src in sources {
let refs = vec![
ExternalEvaluatorRef::Rego {
source: src.clone(),
},
ExternalEvaluatorRef::Cedar {
source: src.clone(),
},
ExternalEvaluatorRef::Wasm {
source: src.clone(),
},
];
for r in refs {
let j = serde_json::to_value(&r).unwrap();
assert!(j.get("kind").is_some());
let back: ExternalEvaluatorRef = serde_json::from_value(j).unwrap();
assert_eq!(r, back);
}
}
}
#[test]
fn policy_signature_roundtrip() {
let sig = PolicySignature::Ed25519 {
signer_key_id: "key-001".into(),
signature_hex: "a".repeat(128),
};
let j = serde_json::to_value(&sig).unwrap();
assert_eq!(j["algorithm"], "ed25519");
let back: PolicySignature = serde_json::from_value(j).unwrap();
assert_eq!(sig, back);
}
#[test]
fn approval_rule_timeout_roundtrips_as_millis() {
let rule = ApprovalRule {
action: "deploy".into(),
approvers: vec!["ops".into()],
timeout: Some(Duration::from_secs(7200)),
fallback: FallbackAction::Block,
};
let j = serde_json::to_value(&rule).unwrap();
assert_eq!(j["timeout"], 7_200_000u64);
let back: ApprovalRule = serde_json::from_value(j).unwrap();
assert_eq!(back.timeout, Some(Duration::from_secs(7200)));
}
#[test]
fn approval_rule_no_timeout_omitted_from_json() {
let rule = ApprovalRule {
action: "deploy".into(),
approvers: vec![],
timeout: None,
fallback: FallbackAction::Block,
};
let j = serde_json::to_string(&rule).unwrap();
assert!(
!j.contains("timeout"),
"None timeout should be omitted: {j}"
);
}
#[test]
fn authorized_action_optional_condition_omitted_when_none() {
let a = AuthorizedAction {
action: "restart".into(),
condition: None,
preconditions: vec![],
};
let j = serde_json::to_string(&a).unwrap();
assert!(
!j.contains("\"condition\""),
"None condition should be omitted: {j}"
);
}
#[test]
fn severity_default_is_low() {
assert_eq!(Severity::default(), Severity::Low);
}
#[test]
fn change_proposal_defaults() {
let p = ChangeProposal::new("act", "agent", "intent", "preferred");
assert!(p.tokens.is_empty());
assert!(p.attached_fields.is_empty());
assert!(p.alternatives.is_empty());
}
}