agentguard_core/authorize/
engine.rs1use 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#[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 #[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
55fn 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
99pub struct Authorizer {
101 inner: CedarAuthorizer,
102 store: PolicyStore,
103 schema: Option<cedar_policy::Schema>,
104 policies: PolicySet,
105 cache: Option<Arc<DecisionCache>>,
108}
109
110impl Authorizer {
111 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 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 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 pub fn cache(&self) -> Option<&Arc<DecisionCache>> {
172 self.cache.as_ref()
173 }
174
175 pub fn invalidate_cache(&self) {
178 if let Some(c) = &self.cache {
179 c.invalidate_all();
180 }
181 }
182
183 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 pub fn authorize(&self, req: &AgentRequest, entities: &Entities) -> Result<Decision> {
240 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 let trace = build_trace(&resp, &policies, &reasons);
280
281 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 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 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 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 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 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 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 let d1 = authorizer
450 .authorize(&req, &cedar_policy::Entities::empty())
451 .unwrap();
452 assert!(!d1.from_cache);
453 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 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 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}