1use std::collections::HashMap;
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::selector::Selector;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Policy {
18 pub path: String,
20 pub version: u64,
23
24 pub situation: String,
26 pub situation_selector: Selector,
28
29 #[serde(default)]
30 pub allow: Vec<AuthorizedAction>,
31 #[serde(default)]
32 pub deny: Vec<AuthorizedAction>,
33 #[serde(default)]
34 pub require_approval: Vec<ApprovalRule>,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub procedure: Option<Vec<ProcedureStep>>,
38
39 #[serde(default)]
43 pub triggers: Vec<String>,
44 #[serde(default)]
48 pub required_fields: Vec<String>,
49 #[serde(default)]
52 pub severity: Severity,
53
54 pub proposed_by: String,
55 pub proposed_at: DateTime<Utc>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub ratified_by: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub ratified_at: Option<DateTime<Utc>>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub ratification_reasoning: Option<String>,
63
64 pub active_from: DateTime<Utc>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub expires_at: Option<DateTime<Utc>>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub supersedes: Option<String>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub signature: Option<PolicySignature>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub tenant_id: Option<String>,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub external_evaluator: Option<ExternalEvaluatorRef>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum ExternalEvaluatorRef {
110 Rego { source: EvaluatorSource },
111 Cedar { source: EvaluatorSource },
112 Wasm { source: EvaluatorSource },
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum EvaluatorSource {
120 Inline { body: String },
122 FilePath { path: std::path::PathBuf },
124 CommitRef { path: String },
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(tag = "algorithm", rename_all = "snake_case")]
139pub enum PolicySignature {
140 Ed25519 {
141 signer_key_id: String,
143 signature_hex: String,
145 },
146}
147
148impl Policy {
149 pub fn is_ratified(&self) -> bool {
151 self.ratified_by.is_some()
152 }
153
154 pub fn is_currently_active(&self, now: DateTime<Utc>) -> bool {
161 if !self.is_ratified() || self.active_from > now {
162 return false;
163 }
164 !matches!(self.expires_at, Some(exp) if exp <= now)
165 }
166
167 pub fn handle(&self) -> String {
169 format!("{}@{}", self.path, self.version)
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct AuthorizedAction {
176 pub action: String,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub condition: Option<String>,
179 #[serde(default)]
180 pub preconditions: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct ApprovalRule {
187 pub action: String,
188 pub approvers: Vec<String>,
189 #[serde(
190 default,
191 skip_serializing_if = "Option::is_none",
192 with = "duration_opt"
193 )]
194 pub timeout: Option<Duration>,
195 pub fallback: FallbackAction,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct ProcedureStep {
201 pub action: String,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub if_previous_failed: Option<String>,
204}
205
206#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
208#[serde(rename_all = "snake_case")]
209pub enum Severity {
210 #[default]
211 Low,
212 Medium,
213 High,
214 Critical,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219#[serde(tag = "kind", rename_all = "snake_case")]
220pub enum FallbackAction {
221 Block,
223 PickAlternative { action: String },
225 LowestRiskAlternative,
227 KeepCurrentState,
230 DelegateTo { policy_path: String },
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
236pub struct ChangeProposal {
237 pub action: String,
238 pub agent_id: String,
239 pub intent: String,
240 pub preferred_option: String,
241 #[serde(default)]
242 pub alternatives: Vec<String>,
243 #[serde(default)]
244 pub tokens: Vec<String>,
245 #[serde(default)]
246 pub attached_fields: HashMap<String, String>,
247}
248
249impl ChangeProposal {
250 pub fn new(
251 action: impl Into<String>,
252 agent_id: impl Into<String>,
253 intent: impl Into<String>,
254 preferred_option: impl Into<String>,
255 ) -> Self {
256 Self {
257 action: action.into(),
258 agent_id: agent_id.into(),
259 intent: intent.into(),
260 preferred_option: preferred_option.into(),
261 alternatives: Vec::new(),
262 tokens: Vec::new(),
263 attached_fields: HashMap::new(),
264 }
265 }
266
267 pub fn with_tokens<I, S>(mut self, tokens: I) -> Self
268 where
269 I: IntoIterator<Item = S>,
270 S: Into<String>,
271 {
272 self.tokens = tokens.into_iter().map(Into::into).collect();
273 self
274 }
275
276 pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
277 self.attached_fields.insert(key.into(), value.into());
278 self
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284#[serde(tag = "kind", rename_all = "snake_case")]
285pub enum Decision {
286 Allow {
287 matched_policy: String,
288 #[serde(default)]
289 preconditions: Vec<String>,
290 },
291 Deny {
292 matched_policy: String,
293 reason: String,
294 },
295 RequireApproval {
296 matched_policy: String,
297 approvers: Vec<String>,
298 #[serde(default, with = "duration_opt")]
299 timeout: Option<Duration>,
300 fallback: FallbackAction,
301 #[serde(default)]
302 approval_task_path: Option<String>,
303 },
304 NoPolicyMatch,
305}
306
307impl Decision {
308 pub fn kind(&self) -> &'static str {
312 match self {
313 Decision::Allow { .. } => "allow",
314 Decision::Deny { .. } => "deny",
315 Decision::RequireApproval { .. } => "require_approval",
316 Decision::NoPolicyMatch => "no_policy_match",
317 }
318 }
319}
320
321mod duration_opt {
323 use std::time::Duration;
324
325 use serde::{Deserialize, Deserializer, Serialize, Serializer};
326
327 pub fn serialize<S>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error>
328 where
329 S: Serializer,
330 {
331 match d {
332 Some(d) => (d.as_millis() as u64).serialize(s),
333 None => Option::<u64>::None.serialize(s),
334 }
335 }
336
337 pub fn deserialize<'de, D>(d: D) -> Result<Option<Duration>, D::Error>
338 where
339 D: Deserializer<'de>,
340 {
341 let opt = Option::<u64>::deserialize(d)?;
342 Ok(opt.map(Duration::from_millis))
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn severity_roundtrips() {
352 for s in [
353 Severity::Low,
354 Severity::Medium,
355 Severity::High,
356 Severity::Critical,
357 ] {
358 let j = serde_json::to_value(s).unwrap();
359 let back: Severity = serde_json::from_value(j).unwrap();
360 assert_eq!(s, back);
361 }
362 }
363
364 #[test]
365 fn fallback_action_all_variants_serialize() {
366 let variants = vec![
367 FallbackAction::Block,
368 FallbackAction::PickAlternative {
369 action: "apply_safe".into(),
370 },
371 FallbackAction::LowestRiskAlternative,
372 FallbackAction::KeepCurrentState,
373 FallbackAction::DelegateTo {
374 policy_path: "infra/fallback".into(),
375 },
376 ];
377 for v in variants {
378 let json = serde_json::to_value(&v).unwrap();
379 assert!(json.get("kind").is_some(), "tagged enum should emit kind");
380 let back: FallbackAction = serde_json::from_value(json).unwrap();
381 assert_eq!(v, back);
382 }
383 }
384
385 #[test]
386 fn decision_roundtrips() {
387 let d = Decision::RequireApproval {
388 matched_policy: "infra/k8s/pod-failing@1".into(),
389 approvers: vec!["human".into()],
390 timeout: Some(Duration::from_secs(3600)),
391 fallback: FallbackAction::LowestRiskAlternative,
392 approval_task_path: None,
393 };
394 let j = serde_json::to_value(&d).unwrap();
395 let back: Decision = serde_json::from_value(j).unwrap();
396 assert_eq!(d, back);
397 }
398
399 #[test]
400 fn change_proposal_builder() {
401 let p = ChangeProposal::new("promote", "agent-1", "merge option C", "spec-7")
402 .with_tokens(["reindex", "downtime"])
403 .with_field("estimated_downtime", "5m");
404 assert_eq!(
405 p.tokens,
406 vec!["reindex".to_string(), "downtime".to_string()]
407 );
408 assert_eq!(p.attached_fields.get("estimated_downtime").unwrap(), "5m");
409 }
410
411 fn base_policy(path: &str) -> Policy {
414 use crate::selector::Selector;
415 Policy {
416 path: path.into(),
417 version: 1,
418 situation: "test".into(),
419 situation_selector: Selector::Always,
420 allow: vec![],
421 deny: vec![],
422 require_approval: vec![],
423 procedure: None,
424 triggers: vec![],
425 required_fields: vec![],
426 severity: Severity::default(),
427 proposed_by: "claude".into(),
428 proposed_at: Utc::now(),
429 ratified_by: None,
430 ratified_at: None,
431 ratification_reasoning: None,
432 active_from: Utc::now(),
433 expires_at: None,
434 supersedes: None,
435 signature: None,
436 tenant_id: None,
437 external_evaluator: None,
438 }
439 }
440
441 #[test]
442 fn is_ratified_true_when_ratified_by_set() {
443 let mut p = base_policy("a");
444 p.ratified_by = Some("alice".into());
445 assert!(p.is_ratified());
446 }
447
448 #[test]
449 fn is_ratified_false_when_none() {
450 assert!(!base_policy("a").is_ratified());
451 }
452
453 #[test]
454 fn is_currently_active_requires_ratification() {
455 let p = base_policy("a");
456 assert!(!p.is_currently_active(Utc::now()));
457 }
458
459 #[test]
460 fn is_currently_active_true_when_ratified_and_in_window() {
461 use chrono::Duration;
462 let mut p = base_policy("a");
463 p.ratified_by = Some("alice".into());
464 p.active_from = Utc::now() - Duration::hours(1);
465 assert!(p.is_currently_active(Utc::now()));
466 }
467
468 #[test]
469 fn is_currently_active_false_when_future_active_from() {
470 use chrono::Duration;
471 let mut p = base_policy("a");
472 p.ratified_by = Some("alice".into());
473 p.active_from = Utc::now() + Duration::hours(1);
474 assert!(!p.is_currently_active(Utc::now()));
475 }
476
477 #[test]
478 fn is_currently_active_false_when_expired() {
479 use chrono::Duration;
480 let mut p = base_policy("a");
481 p.ratified_by = Some("alice".into());
482 p.active_from = Utc::now() - Duration::hours(2);
483 p.expires_at = Some(Utc::now() - Duration::hours(1)); assert!(!p.is_currently_active(Utc::now()));
485 }
486
487 #[test]
488 fn is_currently_active_false_when_expires_at_equals_now() {
489 let now = Utc::now();
491 let mut p = base_policy("a");
492 p.ratified_by = Some("alice".into());
493 p.active_from = now;
494 p.expires_at = Some(now);
495 assert!(!p.is_currently_active(now));
496 }
497
498 #[test]
499 fn is_currently_active_true_just_before_expiry() {
500 use chrono::Duration;
501 let now = Utc::now();
502 let mut p = base_policy("a");
503 p.ratified_by = Some("alice".into());
504 p.active_from = now - Duration::hours(1);
505 p.expires_at = Some(now + Duration::seconds(1));
506 assert!(p.is_currently_active(now));
507 }
508
509 #[test]
510 fn policy_handle_format() {
511 let mut p = base_policy("infra/k8s/pod-failing");
512 p.version = 3;
513 assert_eq!(p.handle(), "infra/k8s/pod-failing@3");
514 }
515
516 #[test]
519 fn external_evaluator_ref_all_variants_roundtrip() {
520 use std::path::PathBuf;
521 let sources = vec![
522 EvaluatorSource::Inline {
523 body: "package p\ndefault allow = false".into(),
524 },
525 EvaluatorSource::FilePath {
526 path: PathBuf::from("/policies/deny.rego"),
527 },
528 EvaluatorSource::CommitRef {
529 path: "/_policy/rego/main".into(),
530 },
531 ];
532 for src in sources {
533 let refs = vec![
534 ExternalEvaluatorRef::Rego {
535 source: src.clone(),
536 },
537 ExternalEvaluatorRef::Cedar {
538 source: src.clone(),
539 },
540 ExternalEvaluatorRef::Wasm {
541 source: src.clone(),
542 },
543 ];
544 for r in refs {
545 let j = serde_json::to_value(&r).unwrap();
546 assert!(j.get("kind").is_some());
547 let back: ExternalEvaluatorRef = serde_json::from_value(j).unwrap();
548 assert_eq!(r, back);
549 }
550 }
551 }
552
553 #[test]
554 fn policy_signature_roundtrip() {
555 let sig = PolicySignature::Ed25519 {
556 signer_key_id: "key-001".into(),
557 signature_hex: "a".repeat(128),
558 };
559 let j = serde_json::to_value(&sig).unwrap();
560 assert_eq!(j["algorithm"], "ed25519");
561 let back: PolicySignature = serde_json::from_value(j).unwrap();
562 assert_eq!(sig, back);
563 }
564
565 #[test]
566 fn approval_rule_timeout_roundtrips_as_millis() {
567 let rule = ApprovalRule {
568 action: "deploy".into(),
569 approvers: vec!["ops".into()],
570 timeout: Some(Duration::from_secs(7200)),
571 fallback: FallbackAction::Block,
572 };
573 let j = serde_json::to_value(&rule).unwrap();
574 assert_eq!(j["timeout"], 7_200_000u64);
575 let back: ApprovalRule = serde_json::from_value(j).unwrap();
576 assert_eq!(back.timeout, Some(Duration::from_secs(7200)));
577 }
578
579 #[test]
580 fn approval_rule_no_timeout_omitted_from_json() {
581 let rule = ApprovalRule {
582 action: "deploy".into(),
583 approvers: vec![],
584 timeout: None,
585 fallback: FallbackAction::Block,
586 };
587 let j = serde_json::to_string(&rule).unwrap();
588 assert!(
589 !j.contains("timeout"),
590 "None timeout should be omitted: {j}"
591 );
592 }
593
594 #[test]
595 fn authorized_action_optional_condition_omitted_when_none() {
596 let a = AuthorizedAction {
597 action: "restart".into(),
598 condition: None,
599 preconditions: vec![],
600 };
601 let j = serde_json::to_string(&a).unwrap();
602 assert!(
603 !j.contains("\"condition\""),
604 "None condition should be omitted: {j}"
605 );
606 }
607
608 #[test]
609 fn severity_default_is_low() {
610 assert_eq!(Severity::default(), Severity::Low);
611 }
612
613 #[test]
614 fn change_proposal_defaults() {
615 let p = ChangeProposal::new("act", "agent", "intent", "preferred");
616 assert!(p.tokens.is_empty());
617 assert!(p.attached_fields.is_empty());
618 assert!(p.alternatives.is_empty());
619 }
620}