1use std::cmp::Ordering;
38
39use crate::auth::action_catalog::{lookup, LifecycleState};
40use crate::serde_json::{self, Value};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Severity {
48 Error,
49 Warning,
50}
51
52impl Severity {
53 pub fn as_str(&self) -> &'static str {
55 match self {
56 Severity::Error => "error",
57 Severity::Warning => "warning",
58 }
59 }
60
61 fn rank(&self) -> u8 {
62 match self {
63 Severity::Error => 0,
64 Severity::Warning => 1,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum DiagnosticCode {
74 UnknownAction,
75 DeprecatedAction,
76 SuspectResource,
77 NoEffectStatements,
78 SelfLockRisk,
79}
80
81impl DiagnosticCode {
82 pub fn as_str(&self) -> &'static str {
83 match self {
84 DiagnosticCode::UnknownAction => "unknown_action",
85 DiagnosticCode::DeprecatedAction => "deprecated_action",
86 DiagnosticCode::SuspectResource => "suspect_resource",
87 DiagnosticCode::NoEffectStatements => "no_effect_statements",
88 DiagnosticCode::SelfLockRisk => "self_lock_risk",
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Diagnostic {
96 pub severity: Severity,
97 pub code: DiagnosticCode,
98 pub message: String,
99 pub suggested_fix: Option<String>,
102 pub location: Option<String>,
104}
105
106impl Diagnostic {
107 pub fn to_json_value(&self) -> Value {
109 use crate::serde_json::Map;
110 let mut obj = Map::new();
111 obj.insert(
112 "severity".into(),
113 Value::String(self.severity.as_str().into()),
114 );
115 obj.insert("code".into(), Value::String(self.code.as_str().into()));
116 obj.insert("message".into(), Value::String(self.message.clone()));
117 obj.insert(
118 "suggested_fix".into(),
119 self.suggested_fix
120 .as_ref()
121 .map(|s| Value::String(s.clone()))
122 .unwrap_or(Value::Null),
123 );
124 obj.insert(
125 "location".into(),
126 self.location
127 .as_ref()
128 .map(|s| Value::String(s.clone()))
129 .unwrap_or(Value::Null),
130 );
131 Value::Object(obj)
132 }
133}
134
135pub fn lint(policy_json: &str) -> Vec<Diagnostic> {
145 let value: Value = match serde_json::from_str(policy_json) {
146 Ok(v) => v,
147 Err(msg) => {
148 return vec![Diagnostic {
149 severity: Severity::Error,
150 code: DiagnosticCode::UnknownAction, message: format!("policy json failed to parse: {msg}"),
152 suggested_fix: None,
153 location: None,
154 }];
155 }
156 };
157 lint_value(&value)
158}
159
160pub fn lint_value(policy: &Value) -> Vec<Diagnostic> {
164 let mut out: Vec<Diagnostic> = Vec::new();
165
166 let Some(obj) = policy.as_object() else {
167 out.push(Diagnostic {
168 severity: Severity::Error,
169 code: DiagnosticCode::UnknownAction,
170 message: "policy json must be an object".into(),
171 suggested_fix: None,
172 location: None,
173 });
174 return finalize(out);
175 };
176 let Some(statements) = obj.get("statements").and_then(|s| s.as_array()) else {
177 out.push(Diagnostic {
178 severity: Severity::Error,
179 code: DiagnosticCode::UnknownAction,
180 message: "policy.statements must be an array".into(),
181 suggested_fix: None,
182 location: None,
183 });
184 return finalize(out);
185 };
186
187 let mut parsed_statements: Vec<ParsedStatement> = Vec::with_capacity(statements.len());
189 for (s_idx, st) in statements.iter().enumerate() {
190 let parsed = lint_statement(s_idx, st, &mut out);
191 parsed_statements.push(parsed);
192 }
193
194 lint_no_effect(&parsed_statements, &mut out);
196
197 lint_self_lock(policy, &parsed_statements, &mut out);
200
201 finalize(out)
202}
203
204fn finalize(mut out: Vec<Diagnostic>) -> Vec<Diagnostic> {
205 out.sort_by(|a, b| {
207 a.severity
208 .rank()
209 .cmp(&b.severity.rank())
210 .then_with(|| a.code.as_str().cmp(b.code.as_str()))
211 .then_with(|| match (&a.location, &b.location) {
212 (Some(x), Some(y)) => x.cmp(y),
213 (Some(_), None) => Ordering::Less,
214 (None, Some(_)) => Ordering::Greater,
215 (None, None) => Ordering::Equal,
216 })
217 .then_with(|| a.message.cmp(&b.message))
218 });
219 out
220}
221
222struct ParsedStatement {
232 effect: Option<String>,
233 actions: Vec<String>,
234 resources: Vec<String>,
235 has_condition: bool,
236 sid: Option<String>,
237}
238
239fn lint_statement(s_idx: usize, st: &Value, out: &mut Vec<Diagnostic>) -> ParsedStatement {
240 let mut parsed = ParsedStatement {
241 effect: None,
242 actions: Vec::new(),
243 resources: Vec::new(),
244 has_condition: false,
245 sid: None,
246 };
247 let Some(obj) = st.as_object() else {
248 return parsed;
249 };
250
251 parsed.effect = obj
252 .get("effect")
253 .and_then(|e| e.as_str())
254 .map(|s| s.to_ascii_lowercase());
255 parsed.has_condition = matches!(obj.get("condition"), Some(c) if !matches!(c, Value::Null));
256 parsed.sid = obj.get("sid").and_then(|s| s.as_str()).map(|s| s.into());
257
258 if let Some(actions) = obj.get("actions").and_then(|a| a.as_array()) {
259 for (a_idx, a) in actions.iter().enumerate() {
260 let Some(name) = a.as_str() else { continue };
261 parsed.actions.push(name.to_string());
262 check_action(s_idx, a_idx, name, out);
263 }
264 }
265 if let Some(resources) = obj.get("resources").and_then(|r| r.as_array()) {
266 for (r_idx, r) in resources.iter().enumerate() {
267 let Some(name) = r.as_str() else { continue };
268 parsed.resources.push(name.to_string());
269 check_resource(s_idx, r_idx, name, out);
270 }
271 }
272 parsed
273}
274
275fn check_action(s_idx: usize, a_idx: usize, name: &str, out: &mut Vec<Diagnostic>) {
276 let location = format!("statements[{s_idx}].actions[{a_idx}]");
277 match lookup(name) {
278 None => {
279 out.push(Diagnostic {
280 severity: Severity::Error,
281 code: DiagnosticCode::UnknownAction,
282 message: format!("action `{name}` is not in the action catalog"),
283 suggested_fix: None,
284 location: Some(location),
285 });
286 }
287 Some(entry) => {
288 if let LifecycleState::Deprecated {
289 replacement,
290 since_version,
291 } = &entry.lifecycle_state
292 {
293 let message = match replacement {
294 Some(r) => format!(
295 "action `{name}` was deprecated in {since_version}; use `{r}` instead",
296 ),
297 None => format!("action `{name}` was deprecated in {since_version}",),
298 };
299 out.push(Diagnostic {
300 severity: Severity::Warning,
301 code: DiagnosticCode::DeprecatedAction,
302 message,
303 suggested_fix: replacement.map(|s| s.to_string()),
304 location: Some(location),
305 });
306 }
307 }
308 }
309}
310
311fn check_resource(s_idx: usize, r_idx: usize, raw: &str, out: &mut Vec<Diagnostic>) {
312 let location = format!("statements[{s_idx}].resources[{r_idx}]");
313 if raw == "*" {
316 out.push(Diagnostic {
317 severity: Severity::Warning,
318 code: DiagnosticCode::SuspectResource,
319 message: "resource `*` matches everything; scope with `<kind>:*` instead".into(),
320 suggested_fix: Some("<kind>:*".into()),
321 location: Some(location),
322 });
323 return;
324 }
325 if !raw.contains(':') {
328 out.push(Diagnostic {
329 severity: Severity::Warning,
330 code: DiagnosticCode::SuspectResource,
331 message: format!(
332 "resource `{raw}` is missing a `<kind>:` prefix (e.g. `table:{raw}`)",
333 ),
334 suggested_fix: Some(format!("<kind>:{raw}")),
335 location: Some(location),
336 });
337 }
338}
339
340fn lint_no_effect(stmts: &[ParsedStatement], out: &mut Vec<Diagnostic>) {
358 for (a_idx, a) in stmts.iter().enumerate() {
359 if a.effect.as_deref() != Some("allow") {
360 continue;
361 }
362 for (d_idx, d) in stmts.iter().enumerate() {
363 if a_idx == d_idx {
364 continue;
365 }
366 if d.effect.as_deref() != Some("deny") {
367 continue;
368 }
369 if d.has_condition {
370 continue;
376 }
377 let action_overlap: Vec<&String> = a
378 .actions
379 .iter()
380 .filter(|x| d.actions.iter().any(|y| y == *x))
381 .collect();
382 if action_overlap.is_empty() {
383 continue;
384 }
385 let resource_overlap: Vec<&String> = a
386 .resources
387 .iter()
388 .filter(|x| d.resources.iter().any(|y| y == *x))
389 .collect();
390 if resource_overlap.is_empty() {
391 continue;
392 }
393 out.push(Diagnostic {
394 severity: Severity::Warning,
395 code: DiagnosticCode::NoEffectStatements,
396 message: format!(
397 "Allow statement is shadowed by unconditional Deny at statements[{d_idx}] \
398 (overlapping actions: {actions:?}, resources: {resources:?})",
399 actions = action_overlap,
400 resources = resource_overlap,
401 ),
402 suggested_fix: Some(
403 "narrow the Deny with a condition, or remove the redundant Allow".into(),
404 ),
405 location: Some(format!("statements[{a_idx}]")),
406 });
407 break;
410 }
411 }
412}
413
414fn lint_self_lock(
428 policy: &Value,
429 parsed_statements: &[ParsedStatement],
430 out: &mut Vec<Diagnostic>,
431) {
432 use std::sync::Arc;
433
434 use crate::auth::policies::Policy;
435 use crate::auth::self_lock_guard::{
436 check_self_lock_invariant, format_block_error, InvariantOutcome,
437 };
438
439 let policy_json = policy.to_string_compact();
440 let Ok(parsed) = Policy::from_json_str(&policy_json) else {
441 return;
442 };
443 let outcome = check_self_lock_invariant(&[Arc::new(parsed)]);
444 let InvariantOutcome::Blocked { ref sid, .. } = outcome else {
445 return;
446 };
447 let Some(message) = format_block_error(&outcome) else {
448 return;
449 };
450
451 let location = sid.as_ref().and_then(|target| {
455 parsed_statements
456 .iter()
457 .position(|st| st.sid.as_deref() == Some(target.as_str()))
458 .map(|idx| format!("statements[{idx}]"))
459 });
460
461 out.push(Diagnostic {
462 severity: Severity::Error,
463 code: DiagnosticCode::SelfLockRisk,
464 message,
465 suggested_fix: Some(
466 "narrow the Deny with a condition (e.g. `platform_scoped: false`) or remove it".into(),
467 ),
468 location,
469 });
470}
471
472#[cfg(test)]
477mod tests {
478 use super::*;
479
480 fn clean_policy() -> &'static str {
481 r#"{
482 "id": "p1",
483 "version": 1,
484 "statements": [
485 {
486 "effect": "allow",
487 "actions": ["select"],
488 "resources": ["table:public.orders"]
489 }
490 ]
491 }"#
492 }
493
494 #[test]
495 fn clean_policy_produces_no_diagnostics() {
496 assert!(lint(clean_policy()).is_empty());
497 }
498
499 #[test]
500 fn unknown_action_is_flagged_as_error() {
501 let p = r#"{"id":"p","version":1,"statements":[
502 {"effect":"allow","actions":["definitely-not-an-action"],"resources":["table:foo"]}
503 ]}"#;
504 let diags = lint(p);
505 assert_eq!(diags.len(), 1, "{diags:?}");
506 assert_eq!(diags[0].code, DiagnosticCode::UnknownAction);
507 assert_eq!(diags[0].severity, Severity::Error);
508 assert_eq!(
509 diags[0].location.as_deref(),
510 Some("statements[0].actions[0]")
511 );
512 }
513
514 #[test]
515 fn deprecated_action_carries_replacement_hint() {
516 let p = r#"{"id":"p","version":1,"statements":[
519 {"effect":"allow","actions":["vault:unseal_history"],"resources":["vault:secret/foo"]}
520 ]}"#;
521 let diags = lint(p);
522 let d = diags
523 .iter()
524 .find(|d| d.code == DiagnosticCode::DeprecatedAction)
525 .expect("deprecated diagnostic");
526 assert_eq!(d.severity, Severity::Warning);
527 assert_eq!(d.suggested_fix.as_deref(), Some("vault:read_metadata"));
528 assert!(d.message.contains("vault:read_metadata"));
529 assert!(d.message.contains("0.5.0"));
530 }
531
532 #[test]
533 fn suspect_resource_triggers_on_bare_star() {
534 let p = r#"{"id":"p","version":1,"statements":[
535 {"effect":"allow","actions":["select"],"resources":["*"]}
536 ]}"#;
537 let diags = lint(p);
538 assert_eq!(diags.len(), 1, "{diags:?}");
539 assert_eq!(diags[0].code, DiagnosticCode::SuspectResource);
540 assert_eq!(diags[0].severity, Severity::Warning);
541 }
542
543 #[test]
544 fn suspect_resource_triggers_on_missing_kind_prefix() {
545 let p = r#"{"id":"p","version":1,"statements":[
546 {"effect":"allow","actions":["select"],"resources":["public.orders"]}
547 ]}"#;
548 let diags = lint(p);
549 assert_eq!(diags.len(), 1, "{diags:?}");
550 assert_eq!(diags[0].code, DiagnosticCode::SuspectResource);
551 assert!(diags[0].message.contains("public.orders"));
552 assert_eq!(
553 diags[0].suggested_fix.as_deref(),
554 Some("<kind>:public.orders")
555 );
556 }
557
558 #[test]
559 fn kind_prefixed_glob_resource_is_clean() {
560 let p = r#"{"id":"p","version":1,"statements":[
561 {"effect":"allow","actions":["select"],"resources":["table:*"]}
562 ]}"#;
563 assert!(lint(p).is_empty());
564 }
565
566 #[test]
567 fn no_effect_triggers_for_overlapping_allow_and_deny() {
568 let p = r#"{"id":"p","version":1,"statements":[
569 {"effect":"allow","actions":["select"],"resources":["table:foo"]},
570 {"effect":"deny","actions":["select"],"resources":["table:foo"]}
571 ]}"#;
572 let diags = lint(p);
573 let d = diags
574 .iter()
575 .find(|d| d.code == DiagnosticCode::NoEffectStatements)
576 .expect("no-effect diagnostic");
577 assert_eq!(d.severity, Severity::Warning);
578 assert_eq!(d.location.as_deref(), Some("statements[0]"));
579 }
580
581 #[test]
582 fn no_effect_is_suppressed_when_deny_has_a_condition() {
583 let p = r#"{"id":"p","version":1,"statements":[
587 {"effect":"allow","actions":["select"],"resources":["table:foo"]},
588 {"effect":"deny","actions":["select"],"resources":["table:foo"],
589 "condition":{"mfa":true}}
590 ]}"#;
591 let diags = lint(p);
592 assert!(
593 !diags
594 .iter()
595 .any(|d| d.code == DiagnosticCode::NoEffectStatements),
596 "{diags:?}"
597 );
598 }
599
600 #[test]
601 fn no_effect_requires_action_overlap() {
602 let p = r#"{"id":"p","version":1,"statements":[
605 {"effect":"allow","actions":["select"],"resources":["table:foo"]},
606 {"effect":"deny","actions":["insert"],"resources":["table:foo"]}
607 ]}"#;
608 let diags = lint(p);
609 assert!(
610 !diags
611 .iter()
612 .any(|d| d.code == DiagnosticCode::NoEffectStatements),
613 "{diags:?}"
614 );
615 }
616
617 #[test]
618 fn diagnostics_sort_errors_before_warnings() {
619 let p = r#"{"id":"p","version":1,"statements":[
623 {"effect":"allow",
624 "actions":["definitely-not-an-action","vault:unseal_history"],
625 "resources":["table:foo","*"]}
626 ]}"#;
627 let diags = lint(p);
628 assert!(diags.len() >= 3, "{diags:?}");
629 assert_eq!(diags[0].severity, Severity::Error);
630 for d in &diags[1..] {
632 assert_eq!(d.severity, Severity::Warning);
633 }
634 }
635
636 #[test]
637 fn invalid_json_returns_single_error_diagnostic() {
638 let diags = lint("{ not json");
639 assert_eq!(diags.len(), 1);
640 assert_eq!(diags[0].severity, Severity::Error);
641 }
642
643 #[test]
644 fn self_lock_risk_flags_deny_detach_on_wildcard() {
645 let p = r#"{
646 "id": "p-brick",
647 "version": 1,
648 "statements": [{
649 "sid": "lock",
650 "effect": "deny",
651 "actions": ["policy:detach"],
652 "resources": ["*"]
653 }]
654 }"#;
655 let diags = lint(p);
656 let d = diags
657 .iter()
658 .find(|d| d.code == DiagnosticCode::SelfLockRisk)
659 .expect("self-lock diagnostic");
660 assert_eq!(d.severity, Severity::Error);
661 assert!(d.message.contains("self-lock invariant"), "{}", d.message);
662 assert!(d.message.contains("p-brick"), "{}", d.message);
663 assert!(d.message.contains("lock"), "{}", d.message);
664 assert_eq!(d.location.as_deref(), Some("statements[0]"));
665 }
666
667 #[test]
668 fn self_lock_risk_message_matches_attach_time_error_verbatim() {
669 use crate::auth::policies::Policy;
670 use crate::auth::self_lock_guard::{check_self_lock_invariant, format_block_error};
671 use std::sync::Arc;
672
673 let raw = r#"{
674 "id": "p-brick",
675 "version": 1,
676 "statements": [{
677 "sid": "lock",
678 "effect": "deny",
679 "actions": ["policy:detach"],
680 "resources": ["*"]
681 }]
682 }"#;
683
684 let diags = lint(raw);
686 let d = diags
687 .iter()
688 .find(|d| d.code == DiagnosticCode::SelfLockRisk)
689 .expect("self-lock diagnostic");
690
691 let policy = Arc::new(Policy::from_json_str(raw).expect("parses"));
693 let outcome = check_self_lock_invariant(&[policy]);
694 let attach_msg = format_block_error(&outcome).expect("blocked carries a message");
695
696 assert_eq!(d.message, attach_msg, "linter must mirror attach error");
697 }
698
699 #[test]
700 fn self_lock_risk_silent_for_narrower_deny() {
701 let p = r#"{
704 "id": "p-narrow",
705 "version": 1,
706 "statements": [{
707 "effect": "deny",
708 "actions": ["policy:detach"],
709 "resources": ["*"],
710 "condition": { "platform_scoped": false }
711 }]
712 }"#;
713 let diags = lint(p);
714 assert!(
715 !diags.iter().any(|d| d.code == DiagnosticCode::SelfLockRisk),
716 "{diags:?}"
717 );
718 }
719
720 #[test]
721 fn self_lock_risk_silent_for_clean_policy() {
722 assert!(!lint(clean_policy())
723 .iter()
724 .any(|d| d.code == DiagnosticCode::SelfLockRisk));
725 }
726
727 #[test]
728 fn diagnostic_json_includes_all_fields() {
729 let d = Diagnostic {
730 severity: Severity::Warning,
731 code: DiagnosticCode::DeprecatedAction,
732 message: "test".into(),
733 suggested_fix: Some("vault:read".into()),
734 location: Some("statements[0].actions[0]".into()),
735 };
736 let s = d.to_json_value().to_string_compact();
737 assert!(s.contains("\"severity\":\"warning\""), "{s}");
738 assert!(s.contains("\"code\":\"deprecated_action\""), "{s}");
739 assert!(s.contains("\"suggested_fix\":\"vault:read\""), "{s}");
740 assert!(
741 s.contains("\"location\":\"statements[0].actions[0]\""),
742 "{s}"
743 );
744 }
745}