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
98#[allow(clippy::too_many_lines)]
99fn collect_predicates_rec(expr: &Expr, predicates: &mut HashSet<String>) {
100    match expr {
101        Expr::True => {
102            predicates.insert("True".into());
103        }
104        Expr::False => {
105            predicates.insert("False".into());
106        }
107        Expr::And(children) | Expr::Or(children) => {
108            for child in children {
109                collect_predicates_rec(child, predicates);
110            }
111        }
112        Expr::Not(inner) => {
113            collect_predicates_rec(inner, predicates);
114        }
115        Expr::HasCapability(cap) => {
116            predicates.insert(format!("HasCapability({})", cap));
117        }
118        Expr::HasAllCapabilities(caps) => {
119            predicates.insert(format!("HasAllCapabilities({:?})", caps));
120        }
121        Expr::HasAnyCapability(caps) => {
122            predicates.insert(format!("HasAnyCapability({:?})", caps));
123        }
124        Expr::IssuerIs(did) => {
125            predicates.insert(format!("IssuerIs({})", did));
126        }
127        Expr::IssuerIn(dids) => {
128            predicates.insert(format!("IssuerIn({:?})", dids));
129        }
130        Expr::SubjectIs(did) => {
131            predicates.insert(format!("SubjectIs({})", did));
132        }
133        Expr::DelegatedBy(did) => {
134            predicates.insert(format!("DelegatedBy({})", did));
135        }
136        Expr::NotRevoked => {
137            predicates.insert("NotRevoked".into());
138        }
139        Expr::NotExpired => {
140            predicates.insert("NotExpired".into());
141        }
142        Expr::ExpiresAfter(secs) => {
143            predicates.insert(format!("ExpiresAfter({})", secs));
144        }
145        Expr::IssuedWithin(secs) => {
146            predicates.insert(format!("IssuedWithin({})", secs));
147        }
148        Expr::RoleIs(role) => {
149            predicates.insert(format!("RoleIs({})", role));
150        }
151        Expr::RoleIn(roles) => {
152            predicates.insert(format!("RoleIn({:?})", roles));
153        }
154        Expr::RepoIs(repo) => {
155            predicates.insert(format!("RepoIs({})", repo));
156        }
157        Expr::RepoIn(repos) => {
158            predicates.insert(format!("RepoIn({:?})", repos));
159        }
160        Expr::RefMatches(pattern) => {
161            predicates.insert(format!("RefMatches({})", pattern));
162        }
163        Expr::PathAllowed(patterns) => {
164            predicates.insert(format!("PathAllowed({:?})", patterns));
165        }
166        Expr::EnvIs(env) => {
167            predicates.insert(format!("EnvIs({})", env));
168        }
169        Expr::EnvIn(envs) => {
170            predicates.insert(format!("EnvIn({:?})", envs));
171        }
172        Expr::WorkloadIssuerIs(issuer) => {
173            predicates.insert(format!("WorkloadIssuerIs({})", issuer));
174        }
175        Expr::WorkloadClaimEquals { key, value } => {
176            predicates.insert(format!("WorkloadClaimEquals({}, {})", key, value));
177        }
178        Expr::MaxChainDepth(depth) => {
179            predicates.insert(format!("MaxChainDepth({})", depth));
180        }
181        Expr::AttrEquals { key, value } => {
182            predicates.insert(format!("AttrEquals({}, {})", key, value));
183        }
184        Expr::AttrIn { key, values } => {
185            predicates.insert(format!("AttrIn({}, {:?})", key, values));
186        }
187        Expr::IsAgent => {
188            predicates.insert("IsAgent".into());
189        }
190        Expr::IsHuman => {
191            predicates.insert("IsHuman".into());
192        }
193        Expr::IsWorkload => {
194            predicates.insert("IsWorkload".into());
195        }
196        Expr::ApprovalGate {
197            inner, approvers, ..
198        } => {
199            predicates.insert(format!("ApprovalGate(approvers={:?})", approvers));
200            collect_predicates_rec(inner, predicates);
201        }
202        _ => {
203            predicates.insert(format!("{:?}", expr));
204        }
205    }
206}
207
208fn removal_risk(pred: &str) -> String {
209    if pred == "NotRevoked" || pred == "NotExpired" || pred.starts_with("MaxChainDepth") {
210        return "HIGH".into();
211    }
212    if pred.starts_with("IssuerIs")
213        || pred.starts_with("RepoIs")
214        || pred.starts_with("RefMatches")
215        || pred.starts_with("EnvIs")
216    {
217        return "MEDIUM".into();
218    }
219    "LOW".into()
220}
221
222fn addition_risk(pred: &str) -> String {
223    if pred.starts_with("HasCapability") || pred.starts_with("HasAllCapabilities") {
224        return "MEDIUM".into();
225    }
226    "LOW".into()
227}
228
229fn check_structural_change(old: &Expr, new: &Expr) -> Option<PolicyChange> {
230    match (old, new) {
231        (Expr::And(_), Expr::Or(_)) => Some(PolicyChange {
232            kind: "changed".into(),
233            description: "And → Or at root".into(),
234            risk: "HIGH".into(),
235        }),
236        (Expr::Or(_), Expr::And(_)) => Some(PolicyChange {
237            kind: "changed".into(),
238            description: "Or → And at root".into(),
239            risk: "MEDIUM".into(),
240        }),
241        _ => None,
242    }
243}