Skip to main content

vfa_tui/models/
policy.rs

1//! Policy engine data models — rules, violations, evaluations, and suppressions.
2
3use serde::{Deserialize, Serialize};
4
5use super::agent::Lifecycle;
6
7/// A single declarative policy rule loaded from `policies.toml`.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PolicyRule {
10    /// Unique rule identifier (e.g. `"require-security-scanner"`).
11    pub id: String,
12    /// The rule kind and its variant-specific parameters.
13    pub rule_type: PolicyRuleType,
14    /// How severe a violation of this rule is.
15    pub severity: Severity,
16    /// Which workspaces this rule applies to.
17    pub scope: PolicyScope,
18    /// Human-readable description of the rule's intent.
19    pub description: String,
20}
21
22/// Discriminator for the kind of policy check being performed.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum PolicyRuleType {
26    /// Require a specific asset to be installed.
27    RequireAsset { asset_id: String },
28    /// Require all agents from a role to be installed.
29    RequireRole { role_id: String },
30    /// Cap the number of stale (version-behind) assets.
31    MaxStale { threshold: u32 },
32    /// Enforce trust-boundary constraints on MCP references.
33    TrustBoundary {
34        max_mutation: bool,
35        max_egress: bool,
36        max_credentials: bool,
37    },
38    /// Enforce a minimum lifecycle stage on installed assets.
39    LifecycleGate { min_stage: Lifecycle },
40}
41
42/// Violation severity level — determines grouping order in the dashboard.
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Severity {
46    /// Highest priority — blocks deployment in most policies.
47    Critical,
48    /// Medium priority — should be addressed but does not block.
49    Warning,
50    /// Low priority — informational only.
51    Info,
52}
53
54/// Defines which workspaces a policy rule applies to.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum PolicyScope {
58    /// Applies to every workspace.
59    All,
60    /// Applies to workspaces whose name matches the glob pattern.
61    NamePattern(String),
62    /// Applies only to workspaces belonging to the named team.
63    Team(String),
64}
65
66/// A single rule violation discovered during a policy evaluation run.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PolicyViolation {
69    /// The rule that was violated.
70    pub rule: PolicyRule,
71    /// Name of the workspace where the violation was found.
72    pub workspace: String,
73    /// Asset identifier involved in the violation, if applicable.
74    pub asset_id: Option<String>,
75    /// ISO 8601 timestamp of when the violation was first detected.
76    pub first_detected: String,
77    /// Detailed description of the violation.
78    pub details: String,
79    /// Suggested remediation action.
80    pub remediation: String,
81}
82
83/// Aggregate evaluation outcome for a single workspace.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PolicyEvaluation {
86    /// Name of the workspace that was evaluated.
87    pub workspace: String,
88    /// Per-rule pass/fail results.
89    pub results: Vec<RuleResult>,
90    /// `(passed / total_applicable) × 100`, rounded half-up.
91    pub compliance_score: f64,
92}
93
94/// Pass/fail result for a single rule within a `PolicyEvaluation`.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RuleResult {
97    /// Identifies the rule that was evaluated.
98    pub rule_id: String,
99    /// `true` if the rule passed; `false` if it was violated.
100    pub passed: bool,
101    /// Optional detail string (populated on failure or for informational rules).
102    pub details: Option<String>,
103}
104
105/// A time-bounded exception that suppresses a specific violation.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Suppression {
108    /// ID of the rule being suppressed.
109    pub rule_id: String,
110    /// Name of the workspace for which the suppression applies.
111    pub workspace: String,
112    /// Human-readable justification.
113    pub reason: String,
114    /// Email address of the person who approved the suppression.
115    pub approver: String,
116    /// ISO 8601 date (`YYYY-MM-DD`) after which the suppression no longer applies.
117    pub expires: String,
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn make_rule(severity: Severity) -> PolicyRule {
125        PolicyRule {
126            id: "test-rule".to_string(),
127            rule_type: PolicyRuleType::RequireAsset {
128                asset_id: "some-asset".to_string(),
129            },
130            severity,
131            scope: PolicyScope::All,
132            description: "Test rule".to_string(),
133        }
134    }
135
136    #[test]
137    fn severity_round_trip() {
138        for s in [Severity::Critical, Severity::Warning, Severity::Info] {
139            let json = serde_json::to_string(&s).unwrap();
140            let decoded: Severity = serde_json::from_str(&json).unwrap();
141            assert_eq!(decoded, s);
142        }
143    }
144
145    #[test]
146    fn severity_serialized_strings() {
147        assert_eq!(
148            serde_json::to_string(&Severity::Critical).unwrap(),
149            "\"critical\""
150        );
151        assert_eq!(
152            serde_json::to_string(&Severity::Warning).unwrap(),
153            "\"warning\""
154        );
155        assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
156    }
157
158    #[test]
159    fn severity_ordering() {
160        // Critical < Warning < Info in Ord (lower = higher priority is intentional —
161        // callers sort ascending to put Critical first).
162        assert!(Severity::Critical < Severity::Warning);
163        assert!(Severity::Warning < Severity::Info);
164    }
165
166    #[test]
167    fn policy_rule_round_trip() {
168        let rule = make_rule(Severity::Critical);
169        let json = serde_json::to_string(&rule).unwrap();
170        let decoded: PolicyRule = serde_json::from_str(&json).unwrap();
171        assert_eq!(decoded.id, "test-rule");
172        assert_eq!(decoded.severity, Severity::Critical);
173    }
174
175    #[test]
176    fn policy_rule_type_require_role_round_trip() {
177        let rt = PolicyRuleType::RequireRole {
178            role_id: "cloud-security-engineer".to_string(),
179        };
180        let json = serde_json::to_string(&rt).unwrap();
181        let decoded: PolicyRuleType = serde_json::from_str(&json).unwrap();
182        assert_eq!(decoded, rt);
183    }
184
185    #[test]
186    fn policy_rule_type_max_stale_round_trip() {
187        let rt = PolicyRuleType::MaxStale { threshold: 5 };
188        let json = serde_json::to_string(&rt).unwrap();
189        let decoded: PolicyRuleType = serde_json::from_str(&json).unwrap();
190        assert_eq!(decoded, rt);
191    }
192
193    #[test]
194    fn policy_rule_type_trust_boundary_round_trip() {
195        let rt = PolicyRuleType::TrustBoundary {
196            max_mutation: false,
197            max_egress: true,
198            max_credentials: true,
199        };
200        let json = serde_json::to_string(&rt).unwrap();
201        let decoded: PolicyRuleType = serde_json::from_str(&json).unwrap();
202        assert_eq!(decoded, rt);
203    }
204
205    #[test]
206    fn policy_rule_type_lifecycle_gate_round_trip() {
207        let rt = PolicyRuleType::LifecycleGate {
208            min_stage: Lifecycle::Stable,
209        };
210        let json = serde_json::to_string(&rt).unwrap();
211        let decoded: PolicyRuleType = serde_json::from_str(&json).unwrap();
212        assert_eq!(decoded, rt);
213    }
214
215    #[test]
216    fn policy_scope_round_trip() {
217        for scope in [
218            PolicyScope::All,
219            PolicyScope::NamePattern("production-*".to_string()),
220            PolicyScope::Team("platform-security".to_string()),
221        ] {
222            let json = serde_json::to_string(&scope).unwrap();
223            let decoded: PolicyScope = serde_json::from_str(&json).unwrap();
224            // Compare via serialized form (PolicyScope doesn't derive PartialEq for simplicity).
225            assert_eq!(
226                serde_json::to_string(&decoded).unwrap(),
227                serde_json::to_string(&scope).unwrap()
228            );
229        }
230    }
231
232    #[test]
233    fn policy_violation_round_trip() {
234        let v = PolicyViolation {
235            rule: make_rule(Severity::Warning),
236            workspace: "prod".to_string(),
237            asset_id: Some("aws-iam-scanner".to_string()),
238            first_detected: "2025-01-01T00:00:00.000Z".to_string(),
239            details: "Asset not found".to_string(),
240            remediation: "Install the asset".to_string(),
241        };
242        let json = serde_json::to_string(&v).unwrap();
243        let decoded: PolicyViolation = serde_json::from_str(&json).unwrap();
244        assert_eq!(decoded.workspace, "prod");
245        assert_eq!(decoded.asset_id, Some("aws-iam-scanner".to_string()));
246    }
247
248    #[test]
249    fn policy_evaluation_round_trip() {
250        let eval = PolicyEvaluation {
251            workspace: "staging".to_string(),
252            results: vec![RuleResult {
253                rule_id: "test-rule".to_string(),
254                passed: true,
255                details: None,
256            }],
257            compliance_score: 100.0,
258        };
259        let json = serde_json::to_string(&eval).unwrap();
260        let decoded: PolicyEvaluation = serde_json::from_str(&json).unwrap();
261        assert_eq!(decoded.workspace, "staging");
262        assert!((decoded.compliance_score - 100.0).abs() < f64::EPSILON);
263    }
264
265    #[test]
266    fn suppression_round_trip() {
267        let s = Suppression {
268            rule_id: "no-mutation-mcp".to_string(),
269            workspace: "staging-infra".to_string(),
270            reason: "Approved exception".to_string(),
271            approver: "security-lead@example.com".to_string(),
272            expires: "2025-06-01".to_string(),
273        };
274        let json = serde_json::to_string(&s).unwrap();
275        let decoded: Suppression = serde_json::from_str(&json).unwrap();
276        assert_eq!(decoded.rule_id, "no-mutation-mcp");
277        assert_eq!(decoded.expires, "2025-06-01");
278    }
279}