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/// A signed policy bundle containing rules and metadata.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct PolicyBundle {
13    pub bundle_id: String,
14    pub version: u32,
15    pub created_at: String,
16    pub rules: Vec<PolicyBundleRule>,
17    pub content_hash: String,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub signature: Option<String>,
20}
21
22/// A rule within a policy bundle — transport-level representation.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PolicyBundleRule {
25    pub rule_id: String,
26    pub level: String,
27    pub effect: String,
28    pub conditions: Value,
29    pub priority: u32,
30}
31
32/// Result of verifying a bundle's integrity.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum BundleVerification {
35    Valid,
36    InvalidHash { expected: String, actual: String },
37    MissingSignature,
38    Unsigned,
39}
40
41/// Computes a BLAKE3 hash of a canonical JSON representation of policy rules.
42pub fn compute_content_hash(rules: &[PolicyBundleRule]) -> String {
43    let rules = Value::Array(
44        rules
45            .iter()
46            .map(|rule| {
47                let mut value = Map::new();
48                value.insert("rule_id".to_string(), Value::String(rule.rule_id.clone()));
49                value.insert("level".to_string(), Value::String(rule.level.clone()));
50                value.insert("effect".to_string(), Value::String(rule.effect.clone()));
51                value.insert(
52                    "conditions".to_string(),
53                    canonicalize_json(&rule.conditions),
54                );
55                value.insert("priority".to_string(), Value::from(rule.priority));
56                canonicalize_json(&Value::Object(value))
57            })
58            .collect(),
59    );
60    let canonical_json =
61        serde_json::to_vec(&rules).expect("policy bundle rules always serialize to JSON");
62    blake3::hash(&canonical_json).to_hex().to_string()
63}
64
65/// Creates an unsigned version-one bundle with an integrity hash.
66pub fn create_bundle(bundle_id: &str, rules: Vec<PolicyBundleRule>) -> PolicyBundle {
67    let content_hash = compute_content_hash(&rules);
68    PolicyBundle {
69        bundle_id: bundle_id.to_string(),
70        version: 1,
71        created_at: Utc::now().to_rfc3339(),
72        rules,
73        content_hash,
74        signature: None,
75    }
76}
77
78/// Verifies that a bundle's rules still match its recorded integrity hash.
79pub fn verify_bundle(bundle: &PolicyBundle) -> BundleVerification {
80    let actual = compute_content_hash(&bundle.rules);
81    if bundle.content_hash != actual {
82        return BundleVerification::InvalidHash {
83            expected: bundle.content_hash.clone(),
84            actual,
85        };
86    }
87
88    match bundle.signature.as_deref() {
89        None => BundleVerification::Unsigned,
90        Some(signature) if signature.trim().is_empty() => BundleVerification::MissingSignature,
91        Some(_) => BundleVerification::Valid,
92    }
93}
94
95/// Loads a JSON policy bundle and rejects a bundle with a mismatched content hash.
96pub fn load_bundle(path: &Path) -> Result<PolicyBundle, String> {
97    let contents = std::fs::read_to_string(path)
98        .map_err(|error| format!("read policy bundle {}: {error}", path.display()))?;
99    let bundle: PolicyBundle = serde_json::from_str(&contents)
100        .map_err(|error| format!("parse policy bundle {}: {error}", path.display()))?;
101
102    if let BundleVerification::InvalidHash { expected, actual } = verify_bundle(&bundle) {
103        return Err(format!(
104            "policy bundle {} has an invalid content hash: expected {expected}, got {actual}",
105            path.display()
106        ));
107    }
108
109    Ok(bundle)
110}
111
112/// Saves a policy bundle as pretty-printed JSON.
113pub fn save_bundle(bundle: &PolicyBundle, path: &Path) -> Result<(), String> {
114    let json = serde_json::to_string_pretty(bundle)
115        .map_err(|error| format!("serialize policy bundle: {error}"))?;
116    std::fs::write(path, format!("{json}\n"))
117        .map_err(|error| format!("write policy bundle {}: {error}", path.display()))
118}
119
120/// Merges overlay rules into a base bundle, preserving base-rule order.
121pub fn merge_bundles(base: &PolicyBundle, overlay: &PolicyBundle) -> PolicyBundle {
122    let base_rule_ids: HashSet<&str> = base
123        .rules
124        .iter()
125        .map(|rule| rule.rule_id.as_str())
126        .collect();
127    let overlay_rules: BTreeMap<&str, &PolicyBundleRule> = overlay
128        .rules
129        .iter()
130        .map(|rule| (rule.rule_id.as_str(), rule))
131        .collect();
132    let mut rules = Vec::with_capacity(base.rules.len() + overlay.rules.len());
133
134    for rule in &base.rules {
135        if let Some(overlay_rule) = overlay_rules.get(rule.rule_id.as_str()) {
136            rules.push((*overlay_rule).clone());
137        } else {
138            rules.push(rule.clone());
139        }
140    }
141    for rule in &overlay.rules {
142        if !base_rule_ids.contains(rule.rule_id.as_str()) {
143            rules.push(rule.clone());
144        }
145    }
146
147    let mut merged = if overlay.version >= base.version {
148        overlay.clone()
149    } else {
150        base.clone()
151    };
152    merged.rules = rules;
153    merged.content_hash = compute_content_hash(&merged.rules);
154    merged
155}
156
157fn canonicalize_json(value: &Value) -> Value {
158    match value {
159        Value::Array(values) => Value::Array(values.iter().map(canonicalize_json).collect()),
160        Value::Object(values) => {
161            let sorted: BTreeMap<_, _> = values.iter().collect();
162            let mut canonical = Map::new();
163            for (key, value) in sorted {
164                canonical.insert(key.clone(), canonicalize_json(value));
165            }
166            Value::Object(canonical)
167        }
168        _ => value.clone(),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use serde_json::json;
175
176    use super::{
177        BundleVerification, PolicyBundleRule, compute_content_hash, create_bundle, load_bundle,
178        merge_bundles, save_bundle, verify_bundle,
179    };
180
181    fn rule(rule_id: &str, effect: &str) -> PolicyBundleRule {
182        PolicyBundleRule {
183            rule_id: rule_id.to_string(),
184            level: "standard".to_string(),
185            effect: effect.to_string(),
186            conditions: json!({"source": "local"}),
187            priority: 10,
188        }
189    }
190
191    #[test]
192    fn created_bundle_verifies_as_unsigned() {
193        let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
194
195        assert_eq!(verify_bundle(&bundle), BundleVerification::Unsigned);
196    }
197
198    #[test]
199    fn tampered_rule_has_an_invalid_hash() {
200        let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
201        bundle.rules[0].effect = "deny".to_string();
202
203        assert!(matches!(
204            verify_bundle(&bundle),
205            BundleVerification::InvalidHash { .. }
206        ));
207    }
208
209    #[test]
210    fn bundle_with_a_signature_verifies_as_valid() {
211        let mut bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
212        bundle.signature = Some("signature".to_string());
213
214        assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
215    }
216
217    #[test]
218    fn save_and_load_round_trip() {
219        let directory = tempfile::tempdir().unwrap();
220        let path = directory.path().join("policy-bundle.json");
221        let bundle = create_bundle("default", vec![rule("allow-local", "allow")]);
222
223        save_bundle(&bundle, &path).unwrap();
224
225        let loaded = load_bundle(&path).unwrap();
226        assert_eq!(loaded.bundle_id, bundle.bundle_id);
227        assert_eq!(loaded.rules[0].rule_id, bundle.rules[0].rule_id);
228        assert_eq!(loaded.content_hash, bundle.content_hash);
229    }
230
231    #[test]
232    fn merge_replaces_matching_rules_and_appends_new_ones() {
233        let base = create_bundle(
234            "base",
235            vec![rule("allow-local", "allow"), rule("deny-remote", "deny")],
236        );
237        let mut overlay = create_bundle(
238            "overlay",
239            vec![rule("allow-local", "deny"), rule("audit", "audit")],
240        );
241        overlay.version = 2;
242
243        let merged = merge_bundles(&base, &overlay);
244
245        assert_eq!(merged.bundle_id, "overlay");
246        assert_eq!(merged.version, 2);
247        assert_eq!(merged.rules.len(), 3);
248        assert_eq!(merged.rules[0].effect, "deny");
249        assert_eq!(merged.rules[1].rule_id, "deny-remote");
250        assert_eq!(merged.rules[2].rule_id, "audit");
251        assert_eq!(merged.content_hash, compute_content_hash(&merged.rules));
252    }
253
254    #[test]
255    fn empty_rules_have_a_stable_valid_hash() {
256        let mut bundle = create_bundle("empty", Vec::new());
257        bundle.signature = Some("signature".to_string());
258
259        assert_eq!(bundle.content_hash, compute_content_hash(&[]));
260        assert_eq!(verify_bundle(&bundle), BundleVerification::Valid);
261    }
262
263    #[test]
264    fn object_key_order_does_not_change_content_hash() {
265        let first = rule("ordered", "allow");
266        let mut second = first.clone();
267        second.conditions = json!({"source": "local", "region": "eu"});
268        let mut reordered = second.clone();
269        reordered.conditions = json!({"region": "eu", "source": "local"});
270
271        assert_eq!(
272            compute_content_hash(&[second]),
273            compute_content_hash(&[reordered])
274        );
275    }
276}