Skip to main content

lean_ctx/core/trust/
policy.rs

1//! Small policy-evaluation primitives.
2
3use serde::{Deserialize, Serialize};
4
5use super::classification::{ClassifiedData, SecurityClassification};
6use super::identity::IdentityContext;
7
8/// Result of a local policy evaluation.
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
10pub enum PolicyDecision {
11    /// The requested action may proceed.
12    Allow,
13    /// The requested action must not proceed.
14    Deny,
15    /// The action may proceed only with an audit/evidence record.
16    Audit,
17}
18
19/// A portable rule descriptor. Enterprise systems may attach richer rule
20/// languages while preserving these interoperable fields.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22pub struct PolicyRule {
23    /// Condition such as `classification>=confidential`, `always`, or `*`.
24    pub condition: String,
25    /// Exact action or prefix wildcard to which the rule applies.
26    pub action: String,
27    /// Whether a matching action must produce evidence.
28    pub evidence_required: bool,
29}
30
31impl PolicyRule {
32    /// Creates a rule descriptor.
33    #[must_use]
34    pub fn new(
35        condition: impl Into<String>,
36        action: impl Into<String>,
37        evidence_required: bool,
38    ) -> Self {
39        Self {
40            condition: condition.into(),
41            action: action.into(),
42            evidence_required,
43        }
44    }
45}
46
47/// Context projection consumed by policy evaluators.
48pub trait PolicyContext {
49    /// Returns a classification when the context carries one.
50    fn classification(&self) -> Option<SecurityClassification>;
51
52    /// Returns an identity when the context carries one.
53    fn identity(&self) -> Option<&IdentityContext>;
54}
55
56impl PolicyContext for IdentityContext {
57    fn classification(&self) -> Option<SecurityClassification> {
58        None
59    }
60
61    fn identity(&self) -> Option<&IdentityContext> {
62        Some(self)
63    }
64}
65
66impl PolicyContext for SecurityClassification {
67    fn classification(&self) -> Option<SecurityClassification> {
68        Some(*self)
69    }
70
71    fn identity(&self) -> Option<&IdentityContext> {
72        None
73    }
74}
75
76impl<T> PolicyContext for ClassifiedData<T> {
77    fn classification(&self) -> Option<SecurityClassification> {
78        Some(self.classification)
79    }
80
81    fn identity(&self) -> Option<&IdentityContext> {
82        None
83    }
84}
85
86/// Interface implemented by local or enterprise policy evaluators.
87pub trait PolicyEvaluator {
88    /// Evaluates `action` against the supplied portable context.
89    fn evaluate(&self, context: &dyn PolicyContext, action: &str) -> PolicyDecision;
90}
91
92/// Reference evaluator that allows every action.
93#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
94pub struct AllowAll;
95
96impl PolicyEvaluator for AllowAll {
97    fn evaluate(&self, _context: &dyn PolicyContext, _action: &str) -> PolicyDecision {
98        PolicyDecision::Allow
99    }
100}
101
102/// Reference evaluator that denies every action.
103#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
104pub struct DenyAll;
105
106impl PolicyEvaluator for DenyAll {
107    fn evaluate(&self, _context: &dyn PolicyContext, _action: &str) -> PolicyDecision {
108        PolicyDecision::Deny
109    }
110}
111
112/// Deterministic policy evaluator that applies a maximum classification and
113/// optional evidence rules.
114#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
115pub struct ClassificationBased {
116    /// Highest classification allowed without a deny decision.
117    pub maximum_allowed: SecurityClassification,
118    /// Additional action/evidence rules.
119    #[serde(default)]
120    pub rules: Vec<PolicyRule>,
121}
122
123impl ClassificationBased {
124    /// Creates a classification gate with the supplied maximum level.
125    #[must_use]
126    pub fn new(maximum_allowed: SecurityClassification) -> Self {
127        Self {
128            maximum_allowed,
129            rules: Vec::new(),
130        }
131    }
132
133    /// Creates a gate with an initial rule set.
134    #[must_use]
135    pub fn from_rules(maximum_allowed: SecurityClassification, rules: Vec<PolicyRule>) -> Self {
136        Self {
137            maximum_allowed,
138            rules,
139        }
140    }
141
142    /// Adds a rule in builder style.
143    #[must_use]
144    pub fn with_rule(mut self, rule: PolicyRule) -> Self {
145        self.rules.push(rule);
146        self
147    }
148
149    /// Adds a rule to the evaluator.
150    pub fn add_rule(&mut self, rule: PolicyRule) {
151        self.rules.push(rule);
152    }
153}
154
155impl PolicyEvaluator for ClassificationBased {
156    fn evaluate(&self, context: &dyn PolicyContext, action: &str) -> PolicyDecision {
157        let Some(classification) = context.classification() else {
158            // A classification gate cannot prove safety for an unclassified
159            // context, so it requests an auditable decision.
160            return PolicyDecision::Audit;
161        };
162
163        if classification > self.maximum_allowed {
164            return PolicyDecision::Deny;
165        }
166
167        for rule in &self.rules {
168            if action_matches(&rule.action, action)
169                && condition_matches(&rule.condition, classification)
170                && rule.evidence_required
171            {
172                return PolicyDecision::Audit;
173            }
174        }
175        PolicyDecision::Allow
176    }
177}
178
179fn action_matches(pattern: &str, action: &str) -> bool {
180    let pattern = pattern.trim();
181    pattern == "*"
182        || pattern.eq_ignore_ascii_case(action.trim())
183        || pattern
184            .strip_suffix('*')
185            .is_some_and(|prefix| action.trim().starts_with(prefix))
186}
187
188fn condition_matches(condition: &str, classification: SecurityClassification) -> bool {
189    let condition = condition.trim();
190    if condition.is_empty() || condition == "*" || condition.eq_ignore_ascii_case("always") {
191        return true;
192    }
193
194    let (operator, value) = if let Some(value) = condition.strip_prefix("classification>=") {
195        (">=", value)
196    } else if let Some(value) = condition.strip_prefix("classification<=") {
197        ("<=", value)
198    } else if let Some(value) = condition.strip_prefix("classification=") {
199        ("=", value)
200    } else if let Some(value) = condition.strip_prefix("classification:") {
201        ("=", value)
202    } else {
203        ("=", condition)
204    };
205
206    let Some(required) = SecurityClassification::parse(value) else {
207        return false;
208    };
209    match operator {
210        ">=" => classification >= required,
211        "<=" => classification <= required,
212        "=" => classification == required,
213        _ => false,
214    }
215}