Skip to main content

lean_ctx/core/ocla/
policy_bundle.rs

1//! Tamper-evident policy bundles for distributing OCLA policy configuration.
2
3use std::collections::{BTreeMap, HashSet};
4use std::path::Path;
5
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10/// Policy criticality classification.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub enum PolicyCriticality {
13    Critical,
14    High,
15    Medium,
16    Low,
17}
18
19/// What happens when a policy expires without renewal.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ExpiryBehavior {
22    FailClosed,
23    FailOpen,
24    GracePeriod,
25}
26
27/// Resolved fail mode for runtime decisions.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ResolvedFailMode {
30    Allow,
31    Deny,
32    DenyAfterGrace { remaining_seconds: u64 },
33}
34
35/// A signed policy bundle containing rules and metadata.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PolicyBundle {
38    pub bundle_id: String,
39    pub version: u32,
40    pub created_at: String,
41    pub rules: Vec<PolicyBundleRule>,
42    pub content_hash: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub signature: Option<String>,
45}
46
47/// A rule within a policy bundle — transport-level representation.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PolicyBundleRule {
50    pub rule_id: String,
51    pub level: String,
52    pub effect: String,
53    pub conditions: Value,
54    pub priority: u32,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub criticality: Option<PolicyCriticality>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub expiry_behavior: Option<ExpiryBehavior>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub grace_period_seconds: Option<u64>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub fallback_policy_ref: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub last_known_good_allowed: Option<bool>,
65}
66
67/// Determines the effective fail mode for an expired policy rule.
68/// `seconds_since_expiry` is how long ago the policy's signature/TTL expired.
69pub fn effective_fail_mode(rule: &PolicyBundleRule, seconds_since_expiry: u64) -> ResolvedFailMode {
70    let behavior = rule
71        .expiry_behavior
72        .as_ref()
73        .unwrap_or(&ExpiryBehavior::FailOpen);
74    match behavior {
75        ExpiryBehavior::FailClosed => ResolvedFailMode::Deny,
76        ExpiryBehavior::FailOpen => ResolvedFailMode::Allow,
77        ExpiryBehavior::GracePeriod => {
78            let grace = rule.grace_period_seconds.unwrap_or(300);
79            if seconds_since_expiry < grace {
80                ResolvedFailMode::DenyAfterGrace {
81                    remaining_seconds: grace - seconds_since_expiry,
82                }
83            } else {
84                ResolvedFailMode::Deny
85            }
86        }
87    }
88}
89
90/// Result of verifying a bundle's integrity.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum BundleVerification {
93    Valid,
94    InvalidHash { expected: String, actual: String },
95    MissingSignature,
96    Unsigned,
97}
98
99/// Computes a BLAKE3 hash of a canonical JSON representation of policy rules.
100pub fn compute_content_hash(rules: &[PolicyBundleRule]) -> String {
101    let rules = Value::Array(
102        rules
103            .iter()
104            .map(|rule| {
105                let mut value = Map::new();
106                value.insert("rule_id".to_string(), Value::String(rule.rule_id.clone()));
107                value.insert("level".to_string(), Value::String(rule.level.clone()));
108                value.insert("effect".to_string(), Value::String(rule.effect.clone()));
109                value.insert(
110                    "conditions".to_string(),
111                    canonicalize_json(&rule.conditions),
112                );
113                value.insert("priority".to_string(), Value::from(rule.priority));
114                if let Some(criticality) = &rule.criticality {
115                    value.insert(
116                        "criticality".to_string(),
117                        serde_json::to_value(criticality)
118                            .expect("policy criticality always serializes to JSON"),
119                    );
120                }
121                if let Some(expiry_behavior) = &rule.expiry_behavior {
122                    value.insert(
123                        "expiry_behavior".to_string(),
124                        serde_json::to_value(expiry_behavior)
125                            .expect("expiry behavior always serializes to JSON"),
126                    );
127                }
128                if let Some(grace_period_seconds) = rule.grace_period_seconds {
129                    value.insert(
130                        "grace_period_seconds".to_string(),
131                        Value::from(grace_period_seconds),
132                    );
133                }
134                if let Some(fallback_policy_ref) = &rule.fallback_policy_ref {
135                    value.insert(
136                        "fallback_policy_ref".to_string(),
137                        Value::String(fallback_policy_ref.clone()),
138                    );
139                }
140                if let Some(last_known_good_allowed) = rule.last_known_good_allowed {
141                    value.insert(
142                        "last_known_good_allowed".to_string(),
143                        Value::Bool(last_known_good_allowed),
144                    );
145                }
146                canonicalize_json(&Value::Object(value))
147            })
148            .collect(),
149    );
150    let canonical_json =
151        serde_json::to_vec(&rules).expect("policy bundle rules always serialize to JSON");
152    blake3::hash(&canonical_json).to_hex().to_string()
153}
154
155/// Creates an unsigned version-one bundle with an integrity hash.
156pub fn create_bundle(bundle_id: &str, rules: Vec<PolicyBundleRule>) -> PolicyBundle {
157    let content_hash = compute_content_hash(&rules);
158    PolicyBundle {
159        bundle_id: bundle_id.to_string(),
160        version: 1,
161        created_at: Utc::now().to_rfc3339(),
162        rules,
163        content_hash,
164        signature: None,
165    }
166}
167
168/// Verifies that a bundle's rules still match its recorded integrity hash.
169pub fn verify_bundle(bundle: &PolicyBundle) -> BundleVerification {
170    let actual = compute_content_hash(&bundle.rules);
171    if bundle.content_hash != actual {
172        return BundleVerification::InvalidHash {
173            expected: bundle.content_hash.clone(),
174            actual,
175        };
176    }
177
178    match bundle.signature.as_deref() {
179        None => BundleVerification::Unsigned,
180        Some(signature) if signature.trim().is_empty() => BundleVerification::MissingSignature,
181        Some(_) => BundleVerification::Valid,
182    }
183}
184
185/// Loads a JSON policy bundle and rejects a bundle with a mismatched content hash.
186pub fn load_bundle(path: &Path) -> Result<PolicyBundle, String> {
187    let contents = std::fs::read_to_string(path)
188        .map_err(|error| format!("read policy bundle {}: {error}", path.display()))?;
189    let bundle: PolicyBundle = serde_json::from_str(&contents)
190        .map_err(|error| format!("parse policy bundle {}: {error}", path.display()))?;
191
192    if let BundleVerification::InvalidHash { expected, actual } = verify_bundle(&bundle) {
193        return Err(format!(
194            "policy bundle {} has an invalid content hash: expected {expected}, got {actual}",
195            path.display()
196        ));
197    }
198
199    Ok(bundle)
200}
201
202/// Saves a policy bundle as pretty-printed JSON.
203pub fn save_bundle(bundle: &PolicyBundle, path: &Path) -> Result<(), String> {
204    let json = serde_json::to_string_pretty(bundle)
205        .map_err(|error| format!("serialize policy bundle: {error}"))?;
206    std::fs::write(path, format!("{json}\n"))
207        .map_err(|error| format!("write policy bundle {}: {error}", path.display()))
208}
209
210/// Merges overlay rules into a base bundle, preserving base-rule order.
211pub fn merge_bundles(base: &PolicyBundle, overlay: &PolicyBundle) -> PolicyBundle {
212    let base_rule_ids: HashSet<&str> = base
213        .rules
214        .iter()
215        .map(|rule| rule.rule_id.as_str())
216        .collect();
217    let overlay_rules: BTreeMap<&str, &PolicyBundleRule> = overlay
218        .rules
219        .iter()
220        .map(|rule| (rule.rule_id.as_str(), rule))
221        .collect();
222    let mut rules = Vec::with_capacity(base.rules.len() + overlay.rules.len());
223
224    for rule in &base.rules {
225        if let Some(overlay_rule) = overlay_rules.get(rule.rule_id.as_str()) {
226            rules.push((*overlay_rule).clone());
227        } else {
228            rules.push(rule.clone());
229        }
230    }
231    for rule in &overlay.rules {
232        if !base_rule_ids.contains(rule.rule_id.as_str()) {
233            rules.push(rule.clone());
234        }
235    }
236
237    let mut merged = if overlay.version >= base.version {
238        overlay.clone()
239    } else {
240        base.clone()
241    };
242    merged.rules = rules;
243    merged.content_hash = compute_content_hash(&merged.rules);
244    merged
245}
246
247fn canonicalize_json(value: &Value) -> Value {
248    match value {
249        Value::Array(values) => Value::Array(values.iter().map(canonicalize_json).collect()),
250        Value::Object(values) => {
251            let sorted: BTreeMap<_, _> = values.iter().collect();
252            let mut canonical = Map::new();
253            for (key, value) in sorted {
254                canonical.insert(key.clone(), canonicalize_json(value));
255            }
256            Value::Object(canonical)
257        }
258        _ => value.clone(),
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use serde_json::{from_value, json, to_value};
265
266    use super::{
267        BundleVerification, ExpiryBehavior, PolicyBundleRule, PolicyCriticality, ResolvedFailMode,
268        compute_content_hash, create_bundle, effective_fail_mode, load_bundle, merge_bundles,
269        save_bundle, verify_bundle,
270    };
271
272    fn rule(rule_id: &str, effect: &str) -> PolicyBundleRule {
273        PolicyBundleRule {
274            rule_id: rule_id.to_string(),
275            level: "standard".to_string(),
276            effect: effect.to_string(),
277            conditions: json!({"source": "local"}),
278            priority: 10,
279            criticality: None,
280            expiry_behavior: None,
281            grace_period_seconds: None,
282            fallback_policy_ref: None,
283            last_known_good_allowed: None,
284        }
285    }
286
287    #[test]
288    fn test_fail_mode_closed() {
289        let mut rule = rule("critical", "deny");
290        rule.criticality = Some(PolicyCriticality::Critical);
291        rule.expiry_behavior = Some(ExpiryBehavior::FailClosed);
292
293        assert_eq!(effective_fail_mode(&rule, 0), ResolvedFailMode::Deny);
294    }
295
296    #[test]
297    fn test_fail_mode_open() {
298        let mut rule = rule("low", "allow");
299        rule.criticality = Some(PolicyCriticality::Low);
300        rule.expiry_behavior = Some(ExpiryBehavior::FailOpen);
301
302        assert_eq!(effective_fail_mode(&rule, 60), ResolvedFailMode::Allow);
303    }
304
305    #[test]
306    fn test_fail_mode_grace_period() {
307        let mut rule = rule("grace", "deny");
308        rule.expiry_behavior = Some(ExpiryBehavior::GracePeriod);
309        rule.grace_period_seconds = Some(120);
310
311        assert_eq!(
312            effective_fail_mode(&rule, 30),
313            ResolvedFailMode::DenyAfterGrace {
314                remaining_seconds: 90,
315            }
316        );
317        assert_eq!(effective_fail_mode(&rule, 120), ResolvedFailMode::Deny);
318    }
319
320    #[test]
321    fn test_backward_compat_no_classification() {
322        let value = json!({
323            "rule_id": "legacy",
324            "level": "standard",
325            "effect": "allow",
326            "conditions": {},
327            "priority": 10
328        });
329        let rule: PolicyBundleRule = from_value(value).unwrap();
330
331        assert_eq!(effective_fail_mode(&rule, 1), ResolvedFailMode::Allow);
332    }
333
334    #[test]
335    fn test_serde_roundtrip_with_classification() {
336        let mut original = rule("classified", "deny");
337        original.criticality = Some(PolicyCriticality::High);
338        original.expiry_behavior = Some(ExpiryBehavior::GracePeriod);
339        original.grace_period_seconds = Some(600);
340        original.fallback_policy_ref = Some("policy://fallback".to_string());
341        original.last_known_good_allowed = Some(true);
342
343        let serialized = to_value(&original).unwrap();
344        let round_tripped: PolicyBundleRule = from_value(serialized).unwrap();
345
346        assert_eq!(round_tripped.rule_id, original.rule_id);
347        assert_eq!(round_tripped.criticality, original.criticality);
348        assert_eq!(round_tripped.expiry_behavior, original.expiry_behavior);
349        assert_eq!(
350            round_tripped.grace_period_seconds,
351            original.grace_period_seconds
352        );
353        assert_eq!(
354            round_tripped.fallback_policy_ref,
355            original.fallback_policy_ref
356        );
357        assert_eq!(
358            round_tripped.last_known_good_allowed,
359            original.last_known_good_allowed
360        );
361    }
362
363    #[test]
364    fn created_bundle_verifies_as_unsigned() {
365        let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
366
367        assert_eq!(verify_bundle(&bundle), BundleVerification::Unsigned);
368    }
369
370    #[test]
371    fn tampered_rule_has_an_invalid_hash() {
372        let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
373        bundle.rules[0].effect = "deny".to_string();
374
375        assert!(matches!(
376            verify_bundle(&bundle),
377            BundleVerification::InvalidHash { .. }
378        ));
379    }
380
381    #[test]
382    fn bundle_with_a_signature_verifies_as_valid() {
383        let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
384        bundle.signature = Some("signature".to_string());
385
386        assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
387    }
388
389    #[test]
390    fn save_and_load_round_trip() {
391        let directory = tempfile::tempdir().unwrap();
392        let path = directory.path().join("policy-bundle.json");
393        let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
394
395        save_bundle(&bundle, &path).unwrap();
396
397        let loaded = load_bundle(&path).unwrap();
398        assert_eq!(loaded.bundle_id, bundle.bundle_id);
399        assert_eq!(loaded.rules[0].rule_id, bundle.rules[0].rule_id);
400        assert_eq!(loaded.content_hash, bundle.content_hash);
401    }
402
403    #[test]
404    fn merge_replaces_matching_rules_and_appends_new_ones() {
405        let base = create_bundle(
406            "base",
407            vec![rule("allow-local", "allow"), rule("deny-remote", "deny")],
408        );
409        let mut overlay = create_bundle(
410            "overlay",
411            vec![rule("allow-local", "deny"), rule("audit", "audit")],
412        );
413        overlay.version = 2;
414
415        let merged = merge_bundles(&base, &overlay);
416
417        assert_eq!(merged.bundle_id, "overlay");
418        assert_eq!(merged.version, 2);
419        assert_eq!(merged.rules.len(), 3);
420        assert_eq!(merged.rules[0].effect, "deny");
421        assert_eq!(merged.rules[1].rule_id, "deny-remote");
422        assert_eq!(merged.rules[2].rule_id, "audit");
423        assert_eq!(merged.content_hash, compute_content_hash(&merged.rules));
424    }
425
426    #[test]
427    fn empty_rules_have_a_stable_valid_hash() {
428        let mut bundle = create_bundle("empty", Vec::new());
429        bundle.signature = Some("signature".to_string());
430
431        assert_eq!(bundle.content_hash, compute_content_hash(&[]));
432        assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
433    }
434
435    #[test]
436    fn object_key_order_does_not_change_content_hash() {
437        let first = rule("ordered", "allow");
438        let mut second = first.clone();
439        second.conditions = json!({"source": "local", "region": "eu"});
440        let mut reordered = second.clone();
441        reordered.conditions = json!({"region": "eu", "source": "local"});
442
443        assert_eq!(
444            compute_content_hash(&[second]),
445            compute_content_hash(&[reordered])
446        );
447    }
448}