agentguard_policy/
lib.rs

1//! Policy operations: versioned bundles, hot reload, diff, blast radius.
2//!
3//! v2.0: provides:
4//! - [`PolicyVersion`] — monotonic version identifiers
5//! - [`PolicyBundle`] — a snapshot of schema + policies, addressable by version
6//! - [`BundleRegistry`] — in-memory version store with persistence
7//! - [`diff`] — line-level diff between two bundles (uses the `similar` crate)
8//! - [`blast_radius::analyze`] — classify decision changes across a corpus
9//! - hot-reload watcher (gated behind the `watch` feature, enabled by default)
10
11pub mod blast_radius;
12pub mod diff;
13pub mod version;
14
15#[cfg(feature = "watch")]
16pub mod watcher;
17
18pub use version::PolicyVersion;
19
20use agentguard_core::Result;
21
22use serde::{Deserialize, Serialize};
23use std::path::Path;
24use std::time::SystemTime;
25
26/// A policy bundle: schema + named policy sources, identified by version.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PolicyBundle {
29    pub version: PolicyVersion,
30    pub tenant_id: String,
31    pub schema_hash: [u8; 32],
32    pub policies_hash: [u8; 32],
33    pub created_at: i64,
34    pub created_by: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub signature: Option<Vec<u8>>,
37    pub schema_source: String,
38    pub policies: Vec<NamedPolicy>,
39}
40
41/// A single named policy within a bundle.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct NamedPolicy {
44    pub id: String,
45    pub source: String,
46}
47
48impl PolicyBundle {
49    /// Compute a stable hash for this bundle (schema hash + policies hash).
50    pub fn content_hash(&self) -> [u8; 32] {
51        use sha2::{Digest, Sha256};
52        let mut hasher = Sha256::new();
53        hasher.update(self.schema_hash);
54        hasher.update(self.policies_hash);
55        hasher.finalize().into()
56    }
57}
58
59/// In-memory registry of policy bundles, keyed by version, scoped per tenant.
60#[derive(Debug, Default)]
61pub struct BundleRegistry {
62    /// tenant_id -> version -> bundle
63    by_tenant: parking_lot::RwLock<
64        std::collections::HashMap<String, std::collections::BTreeMap<u64, PolicyBundle>>,
65    >,
66}
67
68impl BundleRegistry {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Register a bundle. Replaces any existing bundle at the same
74    /// `(tenant, version)`.
75    pub fn register(&self, bundle: PolicyBundle) -> Result<()> {
76        let mut all = self.by_tenant.write();
77        let entry = all.entry(bundle.tenant_id.clone()).or_default();
78        entry.insert(bundle.version.as_u64(), bundle);
79        Ok(())
80    }
81
82    /// Get the latest bundle for a tenant, if any.
83    pub fn latest(&self, tenant_id: &str) -> Option<PolicyBundle> {
84        let all = self.by_tenant.read();
85        all.get(tenant_id)
86            .and_then(|m| m.values().next_back().cloned())
87    }
88
89    /// Get a specific version of a tenant's bundle.
90    pub fn at(&self, tenant_id: &str, version: PolicyVersion) -> Option<PolicyBundle> {
91        let all = self.by_tenant.read();
92        all.get(tenant_id)
93            .and_then(|m| m.get(&version.as_u64()).cloned())
94    }
95
96    /// List all versions for a tenant, oldest first.
97    pub fn list(&self, tenant_id: &str) -> Vec<PolicyBundle> {
98        let all = self.by_tenant.read();
99        all.get(tenant_id)
100            .map(|m| m.values().cloned().collect())
101            .unwrap_or_default()
102    }
103
104    /// Total number of bundles across all tenants.
105    pub fn total(&self) -> usize {
106        let all = self.by_tenant.read();
107        all.values().map(|m| m.len()).sum()
108    }
109}
110
111/// Helper: hash a string with SHA-256.
112pub fn sha256(bytes: &[u8]) -> [u8; 32] {
113    use sha2::{Digest, Sha256};
114    let mut h = Sha256::new();
115    h.update(bytes);
116    h.finalize().into()
117}
118
119/// Helper: current Unix timestamp.
120pub fn now_unix() -> i64 {
121    SystemTime::now()
122        .duration_since(SystemTime::UNIX_EPOCH)
123        .map(|d| d.as_secs() as i64)
124        .unwrap_or(0)
125}
126
127/// Helper: write a bundle to disk as JSON.
128pub fn write_bundle_to_path(bundle: &PolicyBundle, path: impl AsRef<Path>) -> std::io::Result<()> {
129    if let Some(parent) = path.as_ref().parent() {
130        std::fs::create_dir_all(parent)?;
131    }
132    let text = serde_json::to_string_pretty(bundle).map_err(std::io::Error::other)?;
133    std::fs::write(path, text)?;
134    Ok(())
135}
136
137/// Helper: read a bundle from disk.
138pub fn read_bundle_from_path(path: impl AsRef<Path>) -> std::io::Result<PolicyBundle> {
139    let text = std::fs::read_to_string(path)?;
140    serde_json::from_str(&text).map_err(std::io::Error::other)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn bundle(version: u64, tenant: &str) -> PolicyBundle {
148        PolicyBundle {
149            version: PolicyVersion::new(version),
150            tenant_id: tenant.to_string(),
151            schema_hash: [0u8; 32],
152            policies_hash: [0u8; 32],
153            created_at: now_unix(),
154            created_by: "test".into(),
155            signature: None,
156            schema_source: "entity User;".into(),
157            policies: vec![NamedPolicy {
158                id: "p0".into(),
159                source: "permit (principal, action, resource);".into(),
160            }],
161        }
162    }
163
164    #[test]
165    fn registry_keeps_latest() {
166        let reg = BundleRegistry::new();
167        reg.register(bundle(1, "acme")).unwrap();
168        reg.register(bundle(2, "acme")).unwrap();
169        reg.register(bundle(3, "acme")).unwrap();
170        let latest = reg.latest("acme").unwrap();
171        assert_eq!(latest.version.as_u64(), 3);
172        assert_eq!(reg.list("acme").len(), 3);
173    }
174
175    #[test]
176    fn at_returns_specific_version() {
177        let reg = BundleRegistry::new();
178        reg.register(bundle(1, "acme")).unwrap();
179        reg.register(bundle(5, "acme")).unwrap();
180        let v = reg.at("acme", PolicyVersion::new(5)).unwrap();
181        assert_eq!(v.version.as_u64(), 5);
182        assert!(reg.at("acme", PolicyVersion::new(99)).is_none());
183    }
184
185    #[test]
186    fn tenants_are_isolated() {
187        let reg = BundleRegistry::new();
188        reg.register(bundle(1, "acme")).unwrap();
189        reg.register(bundle(2, "globex")).unwrap();
190        assert_eq!(reg.latest("acme").unwrap().version.as_u64(), 1);
191        assert_eq!(reg.latest("globex").unwrap().version.as_u64(), 2);
192    }
193
194    #[test]
195    fn content_hash_is_stable() {
196        let a = bundle(1, "t");
197        let b = bundle(1, "t");
198        assert_eq!(a.content_hash(), b.content_hash());
199    }
200
201    #[test]
202    fn round_trip_via_disk() {
203        let dir = tempfile::tempdir().unwrap();
204        let path = dir.path().join("bundle.json");
205        let b = bundle(7, "tenant-x");
206        write_bundle_to_path(&b, &path).unwrap();
207        let loaded = read_bundle_from_path(&path).unwrap();
208        assert_eq!(loaded.version.as_u64(), 7);
209        assert_eq!(loaded.tenant_id, "tenant-x");
210        assert_eq!(loaded.policies.len(), 1);
211    }
212}