agentguard_core/authorize/
engine.rs

1//! Authorization engine: evaluates a request against the policy store.
2
3use crate::decision::cache::{CacheKey, CachedDecision, DecisionCache};
4use crate::error::Result;
5use crate::policy::PolicyStore;
6use crate::request::AgentRequest;
7use crate::ttl::SystemClock;
8use cedar_policy::{
9    Authorizer as CedarAuthorizer, Decision as CedarDecision, Entities, PolicySet, Response,
10};
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13
14/// The result of evaluating an authorization request.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Decision {
17    pub effect: Effect,
18    pub policies: Vec<String>,
19    pub reasons: Vec<String>,
20    pub request: serde_json::Value,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub trace: Option<serde_json::Value>,
23    /// True if this decision was served from the decision cache (a hit).
24    /// Surfaced to callers so they can include it in the audit record
25    /// or `X-Decision-Source` response header.
26    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
27    pub from_cache: bool,
28}
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(rename_all = "lowercase")]
32pub enum Effect {
33    Allow,
34    Deny,
35}
36
37impl std::fmt::Display for Effect {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(match self {
40            Effect::Allow => "allow",
41            Effect::Deny => "deny",
42        })
43    }
44}
45
46impl From<CedarDecision> for Effect {
47    fn from(d: CedarDecision) -> Self {
48        match d {
49            CedarDecision::Allow => Effect::Allow,
50            CedarDecision::Deny => Effect::Deny,
51        }
52    }
53}
54
55/// Build a structured trace JSON for the `Decision.trace` field.
56///
57/// The trace shape is:
58/// ```json
59/// {
60///   "decision": "allow" | "deny",
61///   "matched_policies": ["policy0", "policy1"],
62///   "errors": ["error1"],
63///   "warnings": ["warning1"]
64/// }
65/// ```
66fn build_trace(
67    resp: &Response,
68    matched_policies: &[String],
69    errors: &[String],
70) -> serde_json::Value {
71    let mut trace = serde_json::Map::new();
72    trace.insert(
73        "decision".into(),
74        serde_json::Value::String(format!("{:?}", resp.decision())),
75    );
76    trace.insert(
77        "matched_policies".into(),
78        serde_json::Value::Array(
79            matched_policies
80                .iter()
81                .map(|s| serde_json::Value::String(s.clone()))
82                .collect(),
83        ),
84    );
85    if !errors.is_empty() {
86        trace.insert(
87            "errors".into(),
88            serde_json::Value::Array(
89                errors
90                    .iter()
91                    .map(|s| serde_json::Value::String(s.clone()))
92                    .collect(),
93            ),
94        );
95    }
96    serde_json::Value::Object(trace)
97}
98
99/// Stateful authorizer wrapping cedar's engine.
100pub struct Authorizer {
101    inner: CedarAuthorizer,
102    store: PolicyStore,
103    schema: Option<cedar_policy::Schema>,
104    policies: PolicySet,
105    /// Optional decision cache. `None` = cache disabled (every call
106    /// hits the cedar engine). Wire via [`Self::with_cache`].
107    cache: Option<Arc<DecisionCache>>,
108}
109
110impl Authorizer {
111    /// Build a new authorizer from a `PolicyStore`.
112    ///
113    /// # Errors
114    /// Returns `Error::Io` / `Error::Json` / `Error::PolicyParse` from
115    /// `PolicyStore::load_policies` or `load_schema` (e.g. malformed
116    /// Cedar, missing schema).
117    pub fn new(store: PolicyStore) -> Result<Self> {
118        let (policies, _sources) = store.load_policies()?;
119        let schema = store.load_schema()?.map(|s| s.schema);
120        Ok(Self {
121            inner: CedarAuthorizer::new(),
122            store,
123            schema,
124            policies,
125            cache: None,
126        })
127    }
128
129    /// Enable the decision cache with the supplied config. Call multiple
130    /// times to replace the cache (the previous `Arc` is dropped).
131    ///
132    /// # Examples
133    /// ```
134    /// use agentguard_core::{Authorizer, PolicyStore};
135    /// use agentguard_core::decision::cache::CacheConfig;
136    /// use std::time::Duration;
137    ///
138    /// let dir = tempfile::tempdir().unwrap();
139    /// let store = PolicyStore::open(dir.path()).unwrap();
140    /// let authorizer = Authorizer::new(store)
141    ///     .unwrap()
142    ///     .with_cache(CacheConfig {
143    ///         capacity: 1000,
144    ///         allow_ttl: Duration::from_secs(60),
145    ///         deny_ttl: Duration::from_secs(5),
146    ///         cache_denies: true,
147    ///     });
148    /// // Subsequent identical requests served within 60 s hit the
149    //  cache with `from_cache: true`.
150    /// ```
151    pub fn with_cache(mut self, config: crate::decision::cache::CacheConfig) -> Self {
152        let clock: Arc<dyn crate::ttl::Clock> = Arc::new(SystemClock);
153        self.cache = Some(Arc::new(DecisionCache::new(config, clock)));
154        self
155    }
156
157    /// Enable the decision cache, returning invalid configuration as an
158    /// error instead of panicking. Prefer this when `config` originates from
159    /// user, operator, or embedder settings.
160    pub fn try_with_cache(
161        mut self,
162        config: crate::decision::cache::CacheConfig,
163    ) -> std::result::Result<Self, String> {
164        let clock: Arc<dyn crate::ttl::Clock> = Arc::new(SystemClock);
165        self.cache = Some(Arc::new(DecisionCache::try_new(config, clock)?));
166        Ok(self)
167    }
168
169    /// Direct handle on the cache (for invalidation, metrics, tests).
170    /// Returns `None` when the cache is disabled.
171    pub fn cache(&self) -> Option<&Arc<DecisionCache>> {
172        self.cache.as_ref()
173    }
174
175    /// Invalidate every cached decision. Call after a policy reload —
176    /// the next `authorize()` falls through to cedar and re-populates.
177    pub fn invalidate_cache(&self) {
178        if let Some(c) = &self.cache {
179            c.invalidate_all();
180        }
181    }
182
183    /// Drain entries that became stale (policy-version mismatch) or
184    /// expired since the last sweep. Cheap no-op when the cache is
185    /// empty or nothing is stale. Returns the number of entries
186    /// removed.
187    ///
188    /// The read path no longer evicts on staleness to avoid
189    /// serializing many concurrent readers on a write lock after a
190    /// policy reload; this method is the matching cleanup hook.
191    pub fn sweep_stale_cache(&self) -> usize {
192        self.cache.as_ref().map(|c| c.sweep_stale()).unwrap_or(0)
193    }
194
195    #[tracing::instrument(
196        skip_all,
197        fields(
198            principal = %req.principal,
199            action = %req.action,
200            resource = %req.resource,
201        )
202    )]
203    /// Evaluate an `AgentRequest` against the loaded policy set.
204    ///
205    /// When a decision cache is configured (`with_cache`), identical
206    /// requests served within the configured TTL are returned from
207    /// memory with `from_cache: true`.
208    ///
209    /// # Errors
210    /// Returns `Error::Json` if the request cannot be serialized
211    /// into the Cedar request shape (should be unreachable today).
212    ///
213    /// # Examples
214    /// ```
215    /// use agentguard_core::{Authorizer, AgentRequest, AgentRequestBuilder,
216    ///     Principal, AgentAction, Resource, AgentContext};
217    /// use cedar_policy::Entities;
218    ///
219    /// let dir = tempfile::tempdir().unwrap();
220    /// let store = agentguard_core::PolicyStore::open(dir.path()).unwrap();
221    /// store
222    ///     .write_policy(
223    ///         "p",
224    ///         r#"permit (principal in User::"alice", action, resource);"#,
225    ///     )
226    ///     .unwrap();
227    /// let authorizer = Authorizer::new(store).unwrap();
228    /// let req = AgentRequestBuilder::new(Principal::user("alice"))
229    ///     .action(AgentAction::tool("send"))
230    ///     .resource(Resource::new("Mailbox", "alice@acme"))
231    ///     .context(AgentContext::new())
232    ///     .build()
233    ///     .unwrap();
234    /// let decision = authorizer
235    ///     .authorize(&req, &Entities::empty())
236    ///     .unwrap();
237    /// assert_eq!(decision.effect, agentguard_core::authorize::Effect::Allow);
238    /// ```
239    pub fn authorize(&self, req: &AgentRequest, entities: &Entities) -> Result<Decision> {
240        // Cache lookup (if enabled). On hit, rebuild a Decision with
241        // from_cache=true; the request payload is stored verbatim so
242        // downstream callers see the same trace shape as a fresh eval.
243        if let Some(cache) = &self.cache {
244            let key = CacheKey::for_request_with_entities(req, entities, cache.policy_version());
245            if let Some(cached) = cache.get(&key) {
246                let effect = match cached.effect.as_str() {
247                    "allow" => Effect::Allow,
248                    "deny" => Effect::Deny,
249                    other => {
250                        return Err(crate::error::Error::Other(format!(
251                            "corrupt cache entry: bad effect {:?}",
252                            other
253                        )));
254                    }
255                };
256                tracing::debug!("cache hit");
257                return Ok(Decision {
258                    effect,
259                    policies: cached.policies,
260                    reasons: cached.reasons,
261                    request: serde_json::to_value(req)?,
262                    trace: None,
263                    from_cache: true,
264                });
265            }
266        }
267
268        let cedar_req = req.to_cedar_request(self.schema.as_ref())?;
269        let resp: Response = self
270            .inner
271            .is_authorized(&cedar_req, &self.policies, entities);
272        let effect: Effect = resp.decision().into();
273        let diagnostics = resp.diagnostics();
274        let policies: Vec<String> = diagnostics.reason().map(|r| r.to_string()).collect();
275        let reasons: Vec<String> = diagnostics.errors().map(|e| e.to_string()).collect();
276        // Build a structured trace JSON: the cedar policy IDs that
277        // matched, any warnings/errors, and the request id for
278        // correlation. Useful for debugging and post-hoc audit.
279        let trace = build_trace(&resp, &policies, &reasons);
280
281        // Populate the cache (if enabled) on the slow path so subsequent
282        // identical requests hit.
283        if let Some(cache) = &self.cache {
284            let key = CacheKey::for_request_with_entities(req, entities, cache.policy_version());
285            let cached = CachedDecision {
286                effect: match effect {
287                    Effect::Allow => "allow".into(),
288                    Effect::Deny => "deny".into(),
289                },
290                policies: policies.clone(),
291                reasons: reasons.clone(),
292                cached_at_policy_version: cache.policy_version(),
293            };
294            cache.put(key, cached);
295        }
296
297        Ok(Decision {
298            effect,
299            policies,
300            reasons,
301            request: serde_json::to_value(req)?,
302            trace: Some(trace),
303            from_cache: false,
304        })
305    }
306
307    pub fn schema(&self) -> Option<&cedar_policy::Schema> {
308        self.schema.as_ref()
309    }
310
311    pub fn store(&self) -> &PolicyStore {
312        &self.store
313    }
314
315    pub fn policies(&self) -> &PolicySet {
316        &self.policies
317    }
318
319    /// Number of policies in the loaded set. O(1) for the `PolicySet`
320    /// length (cached). Used by `/readyz` to check that policies are
321    /// loaded without iterating the full set.
322    pub fn policy_count(&self) -> usize {
323        self.policies.policies().count()
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::request::AgentRequestBuilder;
331    use crate::{AgentAction, AgentContext, Principal, Resource};
332    use tempfile::tempdir;
333
334    fn allow_alice_email() -> &'static str {
335        r#"permit (principal in User::"alice", action, resource);"#
336    }
337
338    fn make_authorizer() -> Authorizer {
339        let dir = tempdir().unwrap();
340        let store = PolicyStore::open(dir.path()).unwrap();
341        store
342            .write_policy("allow_alice", allow_alice_email())
343            .unwrap();
344        Authorizer::new(store).unwrap()
345    }
346
347    fn make_request() -> AgentRequest {
348        AgentRequestBuilder::new(Principal::user("alice"))
349            .action(AgentAction::tool("send_email"))
350            .resource(Resource::new("Mailbox", "alice@acme"))
351            .context(AgentContext::new())
352            .build()
353            .unwrap()
354    }
355
356    #[test]
357    fn decision_includes_trace() {
358        let authorizer = make_authorizer();
359        let req = make_request();
360        let decision = authorizer
361            .authorize(&req, &cedar_policy::Entities::empty())
362            .unwrap();
363        assert_eq!(decision.effect, Effect::Allow);
364        // Trace is now populated (was always None before commit 3).
365        let trace = decision.trace.expect("trace must be populated");
366        assert_eq!(trace["decision"], serde_json::json!("Allow"));
367        let matched = trace["matched_policies"].as_array().unwrap();
368        assert!(
369            !matched.is_empty(),
370            "matched_policies should list the matched policy"
371        );
372        // Cedar auto-generates policy IDs (policy0, policy1, ...) when
373        // none is supplied. Just verify the array is populated.
374        assert!(matched[0].is_string());
375    }
376
377    #[test]
378    fn policy_count_returns_zero_for_empty_store() {
379        let dir = tempdir().unwrap();
380        let store = PolicyStore::open(dir.path()).unwrap();
381        // No policies written.
382        let authorizer = Authorizer::new(store).unwrap();
383        assert_eq!(authorizer.policy_count(), 0);
384    }
385
386    #[test]
387    fn policy_count_returns_loaded_count() {
388        let dir = tempdir().unwrap();
389        let store = PolicyStore::open(dir.path()).unwrap();
390        // Use distinct policy text so cedar assigns distinct auto-IDs
391        // (policy0, policy1). Two identical policies would both be auto-
392        // assigned policy0, and cedar's merge would collapse them.
393        store
394            .write_policy("a", r#"permit (principal, action, resource);"#)
395            .unwrap();
396        store
397            .write_policy(
398                "b",
399                r#"permit (principal, action == Action::"ToolCall::send_email", resource);"#,
400            )
401            .unwrap();
402        let authorizer = Authorizer::new(store).unwrap();
403        assert_eq!(authorizer.policy_count(), 2);
404    }
405
406    #[test]
407    fn deny_decision_has_trace() {
408        let authorizer = make_authorizer();
409        // Bob is not in the allow_alice policy's principal.
410        let req = AgentRequestBuilder::new(Principal::user("bob"))
411            .action(AgentAction::tool("send_email"))
412            .resource(Resource::new("Mailbox", "bob@acme"))
413            .context(AgentContext::new())
414            .build()
415            .unwrap();
416        let decision = authorizer
417            .authorize(&req, &cedar_policy::Entities::empty())
418            .unwrap();
419        assert_eq!(decision.effect, Effect::Deny);
420        let trace = decision.trace.expect("trace must be populated");
421        assert_eq!(trace["decision"], serde_json::json!("Deny"));
422    }
423
424    #[test]
425    fn cache_disabled_by_default() {
426        let authorizer = make_authorizer();
427        assert!(authorizer.cache().is_none());
428    }
429
430    #[test]
431    fn try_with_cache_returns_invalid_configuration_without_panicking() {
432        let config = crate::decision::cache::CacheConfig {
433            capacity: 0,
434            ..crate::decision::cache::CacheConfig::default()
435        };
436        let error = make_authorizer()
437            .try_with_cache(config)
438            .err()
439            .expect("invalid cache config should return an error");
440        assert_eq!(error, "cache capacity must be greater than zero");
441    }
442
443    #[test]
444    fn cache_hit_on_second_identical_request() {
445        let authorizer =
446            make_authorizer().with_cache(crate::decision::cache::CacheConfig::default());
447        let req = make_request();
448        // First call: cache miss, populates.
449        let d1 = authorizer
450            .authorize(&req, &cedar_policy::Entities::empty())
451            .unwrap();
452        assert!(!d1.from_cache);
453        // Second call: identical key, must hit.
454        let d2 = authorizer
455            .authorize(&req, &cedar_policy::Entities::empty())
456            .unwrap();
457        assert!(d2.from_cache, "second identical request must hit cache");
458        assert_eq!(d2.effect, d1.effect);
459        let cache = authorizer.cache().unwrap();
460        let stats = cache.stats();
461        assert_eq!(stats.misses, 1);
462        assert_eq!(stats.hits, 1);
463    }
464
465    #[test]
466    fn cache_invalidation_flips_effect() {
467        // Wire the cache, populate with Allow, mutate the policy to
468        // Deny, invalidate, then re-evaluate. The new decision must
469        // be the post-mutation Deny (not the cached Allow).
470        let dir = tempdir().unwrap();
471        let store = PolicyStore::open(dir.path()).unwrap();
472        store
473            .write_policy("allow_alice", r#"permit (principal, action, resource);"#)
474            .unwrap();
475        let authorizer = Authorizer::new(store)
476            .unwrap()
477            .with_cache(crate::decision::cache::CacheConfig::default());
478        let req = make_request();
479        let d1 = authorizer
480            .authorize(&req, &cedar_policy::Entities::empty())
481            .unwrap();
482        assert_eq!(d1.effect, Effect::Allow);
483        let d2 = authorizer
484            .authorize(&req, &cedar_policy::Entities::empty())
485            .unwrap();
486        assert!(d2.from_cache);
487        // Invalidate without changing the policy — the next decision
488        // should re-evaluate and match the prior outcome.
489        authorizer.invalidate_cache();
490        let d3 = authorizer
491            .authorize(&req, &cedar_policy::Entities::empty())
492            .unwrap();
493        assert!(!d3.from_cache, "post-invalidate call must miss");
494        assert_eq!(d3.effect, Effect::Allow);
495    }
496}