1#![deny(warnings)]
8
9use std::path::Path;
10
11use serde::Deserialize;
12
13use crate::catalog::store::CatalogStore;
14use crate::error::TuiError;
15use crate::models::agent::Lifecycle;
16use crate::models::policy::{PolicyRule, PolicyRuleType, PolicyScope, Severity, Suppression};
17
18#[derive(Debug, Deserialize, Default)]
24struct TomlConfig {
25 #[serde(default)]
26 rule: Vec<TomlRule>,
27 #[serde(default)]
28 suppression: Vec<TomlSuppression>,
29}
30
31#[derive(Debug, Deserialize)]
33struct TomlRule {
34 id: String,
35 #[serde(rename = "type")]
36 rule_type: String,
37 severity: String,
38 scope: toml::Value,
40 description: String,
41 asset_id: Option<String>,
43 role_id: Option<String>,
44 threshold: Option<u32>,
45 max_mutation: Option<bool>,
46 max_egress: Option<bool>,
47 max_credentials: Option<bool>,
48 min_stage: Option<String>,
49}
50
51#[derive(Debug, Deserialize)]
53struct TomlSuppression {
54 rule_id: String,
55 workspace: String,
56 reason: String,
57 #[serde(default)]
58 approver: String,
59 #[serde(default)]
60 expires: String,
61}
62
63#[derive(Debug, Default)]
69pub struct PolicyConfig {
70 pub rules: Vec<PolicyRule>,
72 pub suppressions: Vec<Suppression>,
74 pub no_policies_file: bool,
76 pub parse_errors: Vec<String>,
79}
80
81pub fn load(path: &Path) -> Result<PolicyConfig, TuiError> {
90 if !path.exists() {
91 return Ok(PolicyConfig {
92 no_policies_file: true,
93 ..Default::default()
94 });
95 }
96
97 let content = std::fs::read_to_string(path).map_err(|e| TuiError::PolicyParse {
98 path: path.display().to_string(),
99 line: None,
100 detail: e.to_string(),
101 })?;
102
103 parse_content(&content, path)
104}
105
106pub fn parse_content(content: &str, path: &Path) -> Result<PolicyConfig, TuiError> {
111 let raw: TomlConfig = match toml::from_str(content) {
112 Ok(v) => v,
113 Err(e) => {
114 let line = e.span().map(|span| {
116 let before = content.get(..span.start).unwrap_or("");
117 let n = before.lines().count();
118 if n == 0 {
119 1usize
120 } else {
121 n
122 }
123 });
124 return Err(TuiError::PolicyParse {
125 path: path.display().to_string(),
126 line,
127 detail: e.to_string(),
128 });
129 }
130 };
131
132 let mut rules = Vec::new();
133 let mut parse_errors = Vec::new();
134
135 for toml_rule in raw.rule {
136 match convert_rule(toml_rule, path) {
137 Ok(rule) => rules.push(rule),
138 Err(e) => parse_errors.push(e.to_string()),
139 }
140 }
141
142 let suppressions: Vec<Suppression> = raw
143 .suppression
144 .into_iter()
145 .map(|s| Suppression {
146 rule_id: s.rule_id,
147 workspace: s.workspace,
148 reason: s.reason,
149 approver: s.approver,
150 expires: s.expires,
151 })
152 .collect();
153
154 Ok(PolicyConfig {
155 rules,
156 suppressions,
157 no_policies_file: false,
158 parse_errors,
159 })
160}
161
162pub fn validate_rules(config: &PolicyConfig, catalog: &CatalogStore) -> Vec<TuiError> {
165 let mut errors = Vec::new();
166 let asset_ids = catalog.all_asset_ids();
167
168 for rule in &config.rules {
169 match &rule.rule_type {
170 PolicyRuleType::RequireAsset { asset_id } if !asset_ids.contains(asset_id) => {
171 errors.push(TuiError::PolicyInvalidRule {
172 rule: rule.id.clone(),
173 reason: format!("references nonexistent asset '{}'", asset_id),
174 });
175 }
176 PolicyRuleType::RequireRole { role_id }
177 if !catalog.roles.contains_key(role_id.as_str()) =>
178 {
179 errors.push(TuiError::PolicyInvalidRule {
180 rule: rule.id.clone(),
181 reason: format!("references nonexistent role '{}'", role_id),
182 });
183 }
184 _ => {}
187 }
188 }
189
190 errors
191}
192
193fn convert_severity(s: &str, rule_id: &str, path: &Path) -> Result<Severity, TuiError> {
198 match s.to_lowercase().as_str() {
199 "critical" => Ok(Severity::Critical),
200 "warning" => Ok(Severity::Warning),
201 "info" => Ok(Severity::Info),
202 other => Err(TuiError::PolicyParse {
203 path: path.display().to_string(),
204 line: None,
205 detail: format!("rule '{}': unknown severity '{}'", rule_id, other),
206 }),
207 }
208}
209
210fn convert_scope(val: &toml::Value, rule_id: &str, path: &Path) -> Result<PolicyScope, TuiError> {
211 match val {
212 toml::Value::String(s) if s == "all" => Ok(PolicyScope::All),
213 toml::Value::Table(t) => {
214 if let Some(toml::Value::String(team)) = t.get("team") {
215 Ok(PolicyScope::Team(team.clone()))
216 } else if let Some(toml::Value::String(pat)) = t.get("name_pattern") {
217 Ok(PolicyScope::NamePattern(pat.clone()))
218 } else {
219 Err(TuiError::PolicyParse {
220 path: path.display().to_string(),
221 line: None,
222 detail: format!(
223 "rule '{}': scope table must have 'team' or 'name_pattern'",
224 rule_id
225 ),
226 })
227 }
228 }
229 _ => Err(TuiError::PolicyParse {
230 path: path.display().to_string(),
231 line: None,
232 detail: format!(
233 "rule '{}': scope must be \"all\" or a table {{team=…}} / {{name_pattern=…}}",
234 rule_id
235 ),
236 }),
237 }
238}
239
240fn convert_lifecycle(s: &str, rule_id: &str, path: &Path) -> Result<Lifecycle, TuiError> {
241 match s.to_lowercase().as_str() {
242 "experimental" => Ok(Lifecycle::Experimental),
243 "beta" => Ok(Lifecycle::Beta),
244 "stable" => Ok(Lifecycle::Stable),
245 "deprecated" => Ok(Lifecycle::Deprecated),
246 other => Err(TuiError::PolicyParse {
247 path: path.display().to_string(),
248 line: None,
249 detail: format!("rule '{}': unknown lifecycle stage '{}'", rule_id, other),
250 }),
251 }
252}
253
254fn convert_rule(r: TomlRule, path: &Path) -> Result<PolicyRule, TuiError> {
255 let severity = convert_severity(&r.severity, &r.id, path)?;
256 let scope = convert_scope(&r.scope, &r.id, path)?;
257
258 let rule_type = match r.rule_type.as_str() {
259 "require_asset" => {
260 let asset_id = r.asset_id.ok_or_else(|| TuiError::PolicyParse {
261 path: path.display().to_string(),
262 line: None,
263 detail: format!("rule '{}': require_asset needs 'asset_id'", r.id),
264 })?;
265 PolicyRuleType::RequireAsset { asset_id }
266 }
267 "require_role" => {
268 let role_id = r.role_id.ok_or_else(|| TuiError::PolicyParse {
269 path: path.display().to_string(),
270 line: None,
271 detail: format!("rule '{}': require_role needs 'role_id'", r.id),
272 })?;
273 PolicyRuleType::RequireRole { role_id }
274 }
275 "max_stale" => {
276 let threshold = r.threshold.ok_or_else(|| TuiError::PolicyParse {
277 path: path.display().to_string(),
278 line: None,
279 detail: format!("rule '{}': max_stale needs 'threshold'", r.id),
280 })?;
281 PolicyRuleType::MaxStale { threshold }
282 }
283 "trust_boundary" => {
284 let max_mutation = r.max_mutation.unwrap_or(true);
285 let max_egress = r.max_egress.unwrap_or(true);
286 let max_credentials = r.max_credentials.unwrap_or(true);
287 PolicyRuleType::TrustBoundary {
288 max_mutation,
289 max_egress,
290 max_credentials,
291 }
292 }
293 "lifecycle_gate" => {
294 let stage_str = r.min_stage.ok_or_else(|| TuiError::PolicyParse {
295 path: path.display().to_string(),
296 line: None,
297 detail: format!("rule '{}': lifecycle_gate needs 'min_stage'", r.id),
298 })?;
299 let min_stage = convert_lifecycle(&stage_str, &r.id, path)?;
300 PolicyRuleType::LifecycleGate { min_stage }
301 }
302 other => {
303 return Err(TuiError::PolicyParse {
304 path: path.display().to_string(),
305 line: None,
306 detail: format!("rule '{}': unknown rule type '{}'", r.id, other),
307 });
308 }
309 };
310
311 Ok(PolicyRule {
312 id: r.id,
313 rule_type,
314 severity,
315 scope,
316 description: r.description,
317 })
318}
319
320#[cfg(test)]
325mod tests {
326 use super::*;
327 use std::collections::HashMap;
328
329 fn path() -> &'static Path {
330 Path::new("policies.toml")
331 }
332
333 #[test]
334 fn parse_full_policy_toml() {
335 let toml = r#"
336[[rule]]
337id = "require-scanner"
338type = "require_asset"
339asset_id = "aws-iam-analyzer"
340severity = "critical"
341scope = "all"
342description = "Need scanner"
343
344[[rule]]
345id = "require-role"
346type = "require_role"
347role_id = "cloud-security-engineer"
348severity = "warning"
349scope = { team = "platform-security" }
350description = "Need role"
351
352[[rule]]
353id = "max-stale"
354type = "max_stale"
355threshold = 5
356severity = "warning"
357scope = "all"
358description = "Max stale"
359
360[[rule]]
361id = "trust-boundary"
362type = "trust_boundary"
363max_mutation = false
364max_egress = true
365max_credentials = true
366severity = "critical"
367scope = { name_pattern = "production-*" }
368description = "Trust boundary"
369
370[[rule]]
371id = "lifecycle-gate"
372type = "lifecycle_gate"
373min_stage = "stable"
374severity = "critical"
375scope = { name_pattern = "production-*" }
376description = "Lifecycle gate"
377
378[[suppression]]
379rule_id = "trust-boundary"
380workspace = "staging-infra"
381reason = "Approved"
382approver = "lead@example.com"
383expires = "2025-06-01"
384"#;
385 let cfg = parse_content(toml, path()).unwrap();
386 assert_eq!(cfg.rules.len(), 5);
387 assert_eq!(cfg.suppressions.len(), 1);
388 assert!(!cfg.no_policies_file);
389 assert!(cfg.parse_errors.is_empty());
390 }
391
392 #[test]
393 fn missing_file_gives_empty_config() {
394 let cfg = load(Path::new("/nonexistent/policies.toml")).unwrap();
395 assert!(cfg.no_policies_file);
396 assert!(cfg.rules.is_empty());
397 }
398
399 #[test]
400 fn malformed_rule_is_skipped_but_valid_rules_kept() {
401 let toml = r#"
402[[rule]]
403id = "good-rule"
404type = "max_stale"
405threshold = 3
406severity = "warning"
407scope = "all"
408description = "OK"
409
410[[rule]]
411id = "bad-rule"
412type = "require_asset"
413# missing asset_id — should be skipped
414severity = "critical"
415scope = "all"
416description = "Missing asset_id"
417"#;
418 let cfg = parse_content(toml, path()).unwrap();
419 assert_eq!(cfg.rules.len(), 1, "expected 1 valid rule");
420 assert_eq!(cfg.rules[0].id, "good-rule");
421 assert_eq!(cfg.parse_errors.len(), 1, "expected 1 parse error");
422 }
423
424 #[test]
425 fn validate_rules_catches_nonexistent_asset() {
426 let toml = r#"
427[[rule]]
428id = "bad-asset"
429type = "require_asset"
430asset_id = "nonexistent-asset-xyz"
431severity = "critical"
432scope = "all"
433description = "Bad"
434"#;
435 let cfg = parse_content(toml, path()).unwrap();
436 let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
437 let errors = validate_rules(&cfg, &store);
438 assert_eq!(errors.len(), 1);
439 assert!(
440 matches!(&errors[0], TuiError::PolicyInvalidRule { rule, .. } if rule == "bad-asset")
441 );
442 }
443
444 #[test]
445 fn validate_rules_catches_nonexistent_role() {
446 let toml = r#"
447[[rule]]
448id = "bad-role"
449type = "require_role"
450role_id = "nonexistent-role-xyz"
451severity = "warning"
452scope = "all"
453description = "Bad"
454"#;
455 let cfg = parse_content(toml, path()).unwrap();
456 let store = CatalogStore::from_parts(vec![], vec![], HashMap::new(), vec![], vec![]);
457 let errors = validate_rules(&cfg, &store);
458 assert_eq!(errors.len(), 1);
459 }
460
461 #[test]
462 fn invalid_rule_type_is_collected_as_error() {
463 let toml = r#"
464[[rule]]
465id = "unknown-type"
466type = "future_rule"
467severity = "info"
468scope = "all"
469description = "Future"
470"#;
471 let cfg = parse_content(toml, path()).unwrap();
472 assert!(cfg.rules.is_empty());
473 assert_eq!(cfg.parse_errors.len(), 1);
474 }
475
476 proptest::proptest! {
477 #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
478
479 #[test]
480 fn parse_does_not_panic_on_arbitrary_input(s in ".*") {
481 let _ = parse_content(&s, path());
483 }
484 }
485}