Skip to main content

auths_sdk/workflows/
policy_diff.rs

1//! Semantic policy diff engine.
2//!
3//! Compares two `auths_policy::Expr` trees and returns a structured list of
4//! semantic changes with risk classifications.
5
6use auths_policy::Expr;
7use std::collections::HashSet;
8
9/// A single semantic change between two policy expressions.
10#[derive(Debug, Clone)]
11pub struct PolicyChange {
12    /// The kind of change: `"added"`, `"removed"`, or `"changed"`.
13    pub kind: String,
14    /// Human-readable description of the predicate or structural element that changed.
15    pub description: String,
16    /// Risk classification: `"HIGH"`, `"MEDIUM"`, or `"LOW"`.
17    pub risk: String,
18}
19
20/// Errors from policy diff operations.
21#[derive(Debug, thiserror::Error)]
22pub enum PolicyDiffError {
23    /// The policy expression could not be parsed.
24    #[error("policy parse error: {0}")]
25    Parse(String),
26}
27
28/// Compute the semantic diff between two compiled policy expressions.
29///
30/// Args:
31/// * `old`: The previous policy expression.
32/// * `new`: The updated policy expression.
33///
34/// Usage:
35/// ```ignore
36/// let changes = compute_policy_diff(&old_expr, &new_expr);
37/// let risk = overall_risk_score(&changes);
38/// ```
39pub fn compute_policy_diff(old: &Expr, new: &Expr) -> Vec<PolicyChange> {
40    let mut changes = Vec::new();
41
42    let old_predicates = collect_predicates(old);
43    let new_predicates = collect_predicates(new);
44
45    for pred in &old_predicates {
46        if !new_predicates.contains(pred) {
47            changes.push(PolicyChange {
48                kind: "removed".into(),
49                description: pred.clone(),
50                risk: removal_risk(pred),
51            });
52        }
53    }
54
55    for pred in &new_predicates {
56        if !old_predicates.contains(pred) {
57            changes.push(PolicyChange {
58                kind: "added".into(),
59                description: pred.clone(),
60                risk: addition_risk(pred),
61            });
62        }
63    }
64
65    if let Some(change) = check_structural_change(old, new) {
66        changes.push(change);
67    }
68
69    changes
70}
71
72/// Reduce a list of changes to a single risk label (HIGH > MEDIUM > LOW).
73///
74/// Args:
75/// * `changes`: The list of policy changes to assess.
76///
77/// Usage:
78/// ```ignore
79/// let risk = overall_risk_score(&changes);
80/// assert_eq!(risk, "HIGH");
81/// ```
82pub fn overall_risk_score(changes: &[PolicyChange]) -> String {
83    if changes.iter().any(|c| c.risk == "HIGH") {
84        return "HIGH".into();
85    }
86    if changes.iter().any(|c| c.risk == "MEDIUM") {
87        return "MEDIUM".into();
88    }
89    "LOW".into()
90}
91
92fn collect_predicates(expr: &Expr) -> HashSet<String> {
93    let mut predicates = HashSet::new();
94    collect_predicates_rec(expr, &mut predicates);
95    predicates
96}
97
98fn collect_predicates_rec(expr: &Expr, predicates: &mut HashSet<String>) {
99    match expr {
100        Expr::True => {
101            predicates.insert("True".into());
102        }
103        Expr::False => {
104            predicates.insert("False".into());
105        }
106        Expr::And(children) | Expr::Or(children) => {
107            for child in children {
108                collect_predicates_rec(child, predicates);
109            }
110        }
111        Expr::Not(inner) => {
112            collect_predicates_rec(inner, predicates);
113        }
114        Expr::HasCapability(cap) => {
115            predicates.insert(format!("HasCapability({})", cap));
116        }
117        Expr::HasAllCapabilities(caps) => {
118            predicates.insert(format!("HasAllCapabilities({:?})", caps));
119        }
120        Expr::HasAnyCapability(caps) => {
121            predicates.insert(format!("HasAnyCapability({:?})", caps));
122        }
123        Expr::IssuerIs(did) => {
124            predicates.insert(format!("IssuerIs({})", did));
125        }
126        Expr::IssuerIn(dids) => {
127            predicates.insert(format!("IssuerIn({:?})", dids));
128        }
129        Expr::SubjectIs(did) => {
130            predicates.insert(format!("SubjectIs({})", did));
131        }
132        Expr::DelegatedBy(did) => {
133            predicates.insert(format!("DelegatedBy({})", did));
134        }
135        Expr::NotRevoked => {
136            predicates.insert("NotRevoked".into());
137        }
138        Expr::NotExpired => {
139            predicates.insert("NotExpired".into());
140        }
141        Expr::ExpiresAfter(secs) => {
142            predicates.insert(format!("ExpiresAfter({})", secs));
143        }
144        Expr::IssuedWithin(secs) => {
145            predicates.insert(format!("IssuedWithin({})", secs));
146        }
147        Expr::RoleIs(role) => {
148            predicates.insert(format!("RoleIs({})", role));
149        }
150        Expr::RoleIn(roles) => {
151            predicates.insert(format!("RoleIn({:?})", roles));
152        }
153        Expr::RepoIs(repo) => {
154            predicates.insert(format!("RepoIs({})", repo));
155        }
156        Expr::RepoIn(repos) => {
157            predicates.insert(format!("RepoIn({:?})", repos));
158        }
159        Expr::RefMatches(pattern) => {
160            predicates.insert(format!("RefMatches({})", pattern));
161        }
162        Expr::PathAllowed(patterns) => {
163            predicates.insert(format!("PathAllowed({:?})", patterns));
164        }
165        Expr::EnvIs(env) => {
166            predicates.insert(format!("EnvIs({})", env));
167        }
168        Expr::EnvIn(envs) => {
169            predicates.insert(format!("EnvIn({:?})", envs));
170        }
171        Expr::WorkloadIssuerIs(issuer) => {
172            predicates.insert(format!("WorkloadIssuerIs({})", issuer));
173        }
174        Expr::WorkloadClaimEquals { key, value } => {
175            predicates.insert(format!("WorkloadClaimEquals({}, {})", key, value));
176        }
177        Expr::MaxChainDepth(depth) => {
178            predicates.insert(format!("MaxChainDepth({})", depth));
179        }
180        Expr::AttrEquals { key, value } => {
181            predicates.insert(format!("AttrEquals({}, {})", key, value));
182        }
183        Expr::AttrIn { key, values } => {
184            predicates.insert(format!("AttrIn({}, {:?})", key, values));
185        }
186        Expr::IsAgent => {
187            predicates.insert("IsAgent".into());
188        }
189        Expr::IsHuman => {
190            predicates.insert("IsHuman".into());
191        }
192        Expr::IsWorkload => {
193            predicates.insert("IsWorkload".into());
194        }
195    }
196}
197
198fn removal_risk(pred: &str) -> String {
199    if pred == "NotRevoked" || pred == "NotExpired" || pred.starts_with("MaxChainDepth") {
200        return "HIGH".into();
201    }
202    if pred.starts_with("IssuerIs")
203        || pred.starts_with("RepoIs")
204        || pred.starts_with("RefMatches")
205        || pred.starts_with("EnvIs")
206    {
207        return "MEDIUM".into();
208    }
209    "LOW".into()
210}
211
212fn addition_risk(pred: &str) -> String {
213    if pred.starts_with("HasCapability") || pred.starts_with("HasAllCapabilities") {
214        return "MEDIUM".into();
215    }
216    "LOW".into()
217}
218
219fn check_structural_change(old: &Expr, new: &Expr) -> Option<PolicyChange> {
220    match (old, new) {
221        (Expr::And(_), Expr::Or(_)) => Some(PolicyChange {
222            kind: "changed".into(),
223            description: "And → Or at root".into(),
224            risk: "HIGH".into(),
225        }),
226        (Expr::Or(_), Expr::And(_)) => Some(PolicyChange {
227            kind: "changed".into(),
228            description: "Or → And at root".into(),
229            risk: "MEDIUM".into(),
230        }),
231        _ => None,
232    }
233}