Skip to main content

reddb_server/auth/
policy_linter.rs

1//! `PolicyLinter` — pure-function linter for IAM policy documents.
2//!
3//! Issue #710. Builds on the [`ActionCatalog`](crate::auth::action_catalog)
4//! (S1A, #707) to produce structured diagnostics about a policy *without*
5//! rejecting it. Unlike [`Policy::from_json_str`], which validates
6//! eagerly and refuses to parse a policy that references an unknown
7//! action, the linter walks the raw JSON loosely and emits one
8//! diagnostic per finding so operator tooling can present a complete
9//! report.
10//!
11//! This slice ships four diagnostic kinds:
12//!
13//! * [`DiagnosticCode::UnknownAction`] — action verb absent from the
14//!   catalog (severity: error).
15//! * [`DiagnosticCode::DeprecatedAction`] — action is `Deprecated` in
16//!   the catalog; the diagnostic carries the `replacement` hint from
17//!   the catalog entry (severity: warning).
18//! * [`DiagnosticCode::SuspectResource`] — resource is missing a
19//!   `<kind>:` prefix, or is bare `*` (severity: warning).
20//! * [`DiagnosticCode::NoEffectStatements`] — an `Allow` statement is
21//!   strictly shadowed by a no-condition `Deny` with overlapping
22//!   action/resource sets, making the Allow effectively dead code
23//!   (severity: warning).
24//! * [`DiagnosticCode::SelfLockRisk`] — the candidate policy, if
25//!   attached, would fail the [`crate::auth::self_lock_guard`]
26//!   invariant, i.e. would prevent the synthetic platform owner from
27//!   ever detaching policies again. Severity is `error` because the
28//!   attach-time guard will refuse the same policy.
29//!
30//! ## Diagnostic ordering
31//!
32//! Diagnostics are emitted in a deterministic order: by `(severity,
33//! code, location)` after they have been collected, so two runs on the
34//! same input always produce the same sequence. `Error` sorts before
35//! `Warning` so the most actionable findings surface first.
36
37use std::cmp::Ordering;
38
39use crate::auth::action_catalog::{lookup, LifecycleState};
40use crate::serde_json::{self, Value};
41
42/// Severity of a [`Diagnostic`].
43///
44/// Ordering: `Error < Warning`. The linter sorts by severity ascending
45/// so errors come first in the output stream.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Severity {
48    Error,
49    Warning,
50}
51
52impl Severity {
53    /// Stable lowercase identifier used by the SQL + HTTP surfaces.
54    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/// Stable diagnostic code. Operator tooling matches on the string form
70/// (via [`DiagnosticCode::as_str`]), so these names are part of the
71/// public contract.
72#[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/// One structured finding produced by the linter.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Diagnostic {
96    pub severity: Severity,
97    pub code: DiagnosticCode,
98    pub message: String,
99    /// Optional remediation hint — for `DeprecatedAction` this carries
100    /// the catalog's `replacement` verb verbatim.
101    pub suggested_fix: Option<String>,
102    /// Dotted path inside the policy document (`statements[0].actions[1]`).
103    pub location: Option<String>,
104}
105
106impl Diagnostic {
107    /// JSON encoding shared by the SQL and HTTP surfaces.
108    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
135// ---------------------------------------------------------------------------
136// Entry point
137// ---------------------------------------------------------------------------
138
139/// Lint a policy document supplied as raw JSON.
140///
141/// Always returns a `Vec<Diagnostic>` — a top-level parse failure is
142/// itself encoded as a single `Error` diagnostic so callers don't have
143/// to branch on `Result`. A clean policy returns an empty vec.
144pub 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, // best-effort code reuse — see note
151                message: format!("policy json failed to parse: {msg}"),
152                suggested_fix: None,
153                location: None,
154            }];
155        }
156    };
157    lint_value(&value)
158}
159
160/// Lint an already-parsed [`Value`]. The HTTP and SQL surfaces use
161/// this entry point after they have decoded the body / fetched the
162/// stored document so they don't have to re-stringify.
163pub 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    // Pass 1: per-statement diagnostics (action verbs + resources).
188    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    // Pass 2: cross-statement NoEffectStatements check.
195    lint_no_effect(&parsed_statements, &mut out);
196
197    // Pass 3: SelfLockRisk — reuse the attach-time guard from S6 so
198    // operators see the identical explanation at author time.
199    lint_self_lock(policy, &parsed_statements, &mut out);
200
201    finalize(out)
202}
203
204fn finalize(mut out: Vec<Diagnostic>) -> Vec<Diagnostic> {
205    // Stable ordering: severity (Error < Warning), then code, then location.
206    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
222// ---------------------------------------------------------------------------
223// Statement-level passes
224// ---------------------------------------------------------------------------
225
226/// Loose-parsed shape of a statement used by the cross-statement
227/// no-effect check. We keep raw strings here rather than ActionPattern
228/// / ResourcePattern because the linter must run even on documents
229/// that reference unknown actions, and `compile_action` would silently
230/// promote a typo to `Exact(...)` and hide the bug.
231struct 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    // Bare `*` is the global wildcard — flagged because it is rarely
314    // what an operator means. Use a kind-scoped `<kind>:*` instead.
315    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    // Missing `<kind>:` prefix — e.g. `"orders"` or `"public.orders"`
326    // without a leading namespace.
327    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
340// ---------------------------------------------------------------------------
341// Cross-statement: NoEffectStatements
342// ---------------------------------------------------------------------------
343
344/// Flag Allow statements that are universally shadowed by an
345/// unconditional Deny on the same action/resource pair.
346///
347/// Heuristic: for each `(Allow, Deny)` pair, if
348///
349/// 1. the action sets share at least one entry,
350/// 2. the resource sets share at least one entry, and
351/// 3. the Deny statement has no `condition` (so it always fires when
352///    its action/resource match),
353///
354/// then the Allow's intersected matches will always be overridden by
355/// the Deny. We surface a single diagnostic against the Allow with the
356/// shadowing Deny statement index in the message.
357fn 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                // The Deny only fires when its condition holds — it
371                // does not shadow the Allow universally. The brief's
372                // "disjoint condition sets" wording maps to this:
373                // when the Deny carries a condition, the Allow may
374                // still apply in the complementary window.
375                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            // One diagnostic per Allow is enough — don't spam if
408            // multiple Denys shadow the same Allow.
409            break;
410        }
411    }
412}
413
414// ---------------------------------------------------------------------------
415// SelfLockRisk — author-time mirror of `PolicySelfLockGuard` (S6, #713)
416// ---------------------------------------------------------------------------
417
418/// Feed the candidate policy through the same simulation the attach-time
419/// guard runs and, if the invariant would refuse the attach, surface a
420/// diagnostic whose `message` is the verbatim attach-time error string.
421///
422/// If the policy fails to parse via [`Policy::from_json_str`] — typically
423/// because an `UnknownAction` diagnostic has already flagged a typo — we
424/// skip the check rather than double-reporting. The other diagnostic
425/// kinds tell the operator what's wrong before they ever reach this
426/// pass.
427fn 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    // Best-effort location: if the guard named a sid, map it back to
452    // a statement index in the raw document so operator tooling can
453    // jump to the offending statement.
454    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// ---------------------------------------------------------------------------
473// Tests
474// ---------------------------------------------------------------------------
475
476#[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        // The catalog ships `vault:unseal_history` with replacement
517        // `vault:read_metadata` since 0.5.0.
518        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        // The Deny only fires inside its time window — it does NOT
584        // universally shadow the Allow, so the no-effect heuristic
585        // must not trigger.
586        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        // Allow on `select`, Deny on `insert` — different actions,
603        // not a no-op.
604        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        // Mix one Unknown (Error) with one SuspectResource (Warning)
620        // and one Deprecated (Warning); error must come first, then
621        // warnings in code order.
622        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        // Following diagnostics are warnings.
631        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        // Linter side.
685        let diags = lint(raw);
686        let d = diags
687            .iter()
688            .find(|d| d.code == DiagnosticCode::SelfLockRisk)
689            .expect("self-lock diagnostic");
690
691        // Attach-side error built from the same primitive.
692        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        // The same deny restricted to tenant-scoped principals does
702        // not lock the synthetic platform owner out.
703        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}