Skip to main content

treetop_core/
engine.rs

1use cedar_policy::{
2    Authorizer, Entities, Entity, Policy, PolicyId, PolicySet, Request as CedarRequest, Schema,
3};
4use std::collections::{HashMap, HashSet};
5use std::sync::{Arc, OnceLock};
6use std::time::{Duration, Instant, SystemTime};
7use std::vec;
8
9use crate::labels::LabelRegistry;
10use crate::policy_match::{
11    action_match_reason, matches_effect, principal_match_reason, resource_match_reason,
12};
13use crate::query::{ActionQuery, PrincipalQuery, ResourceQuery};
14use crate::timers::PhaseTimer;
15use crate::traits::CedarAtom;
16use crate::types::{
17    Decision, DecisionDiagnostics, FromDecisionWithPolicy, PermitPolicies, PermitPolicy,
18    PolicyEffectFilter, PolicyMatchReason, PolicyVersion, Request, RequestContext, Resource,
19    UserPolicies,
20};
21use crate::{Groups, Principal};
22use crate::{error::PolicyError, loader};
23use arc_swap::ArcSwap;
24
25use sha2::{Digest, Sha256};
26use tracing::debug;
27#[cfg(feature = "observability")]
28use tracing::info_span;
29
30#[cfg(feature = "observability")]
31use crate::metrics::{
32    EvaluationObservation, EvaluationPhases, MatchedPolicySource, get_sink, metrics_enabled,
33    record_evaluation_observation, record_reload,
34};
35
36/// Static cached Authorizer instance (stateless, reusable across evaluations).
37fn get_authorizer() -> &'static Authorizer {
38    static AUTHORIZER: OnceLock<Authorizer> = OnceLock::new();
39    AUTHORIZER.get_or_init(Authorizer::new)
40}
41
42/// Aggregates timing information for all evaluation phases.
43#[derive(Debug)]
44struct EvalTimers {
45    /// Total elapsed time from start of evaluation
46    total_start: Option<Instant>,
47    /// Whether individual phase timers should sample the clock.
48    measure_enabled: bool,
49    /// Whether debug tracing was enabled when evaluation started.
50    debug_enabled: bool,
51    /// Time spent applying labels
52    labels: Duration,
53    /// Time spent constructing Cedar request
54    construct_req: Duration,
55    /// Time spent building Cedar entities
56    entities: Duration,
57    /// Time spent resolving groups
58    groups: Duration,
59    /// Time spent performing authorization
60    authz: Duration,
61}
62
63impl EvalTimers {
64    fn start(measure_enabled: bool, debug_enabled: bool) -> Self {
65        Self {
66            total_start: measure_enabled.then(Instant::now),
67            measure_enabled,
68            debug_enabled,
69            labels: Duration::ZERO,
70            construct_req: Duration::ZERO,
71            entities: Duration::ZERO,
72            groups: Duration::ZERO,
73            authz: Duration::ZERO,
74        }
75    }
76
77    fn total_elapsed(&self) -> Duration {
78        self.total_start
79            .map_or(Duration::ZERO, |start| start.elapsed())
80    }
81}
82
83/// Result of preparing a request for authorization: Cedar request, entities, snapshot, and phase timings.
84struct PreparedRequest {
85    cedar_req: CedarRequest,
86    entities: Entities,
87    snapshot: Snapshot,
88    timers: EvalTimers,
89    #[cfg(feature = "observability")]
90    sink: crate::metrics::SinkGuard,
91    #[cfg(feature = "observability")]
92    metrics_enabled: bool,
93}
94
95/// Immutable snapshot of a compiled policy set, along with metadata.
96#[derive(Debug)]
97struct PolicySnapshot {
98    set: PolicySet,
99    version: PolicyVersion,
100    permit_policies: HashMap<PolicyId, PermitPolicy>,
101    forbid_policy_ids: HashMap<PolicyId, String>,
102    schema: Option<Arc<Schema>>,
103}
104
105/// Convenience alias for a shared policy snapshot.
106type Snapshot = Arc<PolicySnapshot>;
107
108impl PolicySnapshot {
109    fn from_policy_text(policy_text: &str) -> Result<Self, PolicyError> {
110        Self::from_policy_text_with_schema(policy_text, None)
111    }
112
113    fn from_policy_text_with_schema(
114        policy_text: &str,
115        schema: Option<Arc<Schema>>,
116    ) -> Result<Self, PolicyError> {
117        let set = match schema.as_deref() {
118            Some(schema) => loader::compile_policy_with_schema(policy_text, schema)?,
119            None => loader::compile_policy(policy_text)?,
120        };
121        let permit_policies = loader::precompute_permit_policies(&set)?;
122        let forbid_policy_ids = loader::precompute_forbid_policy_ids(&set);
123
124        let mut hasher = Sha256::new();
125        hasher.update(policy_text.as_bytes());
126        let digest = hasher.finalize();
127        let mut hash = String::with_capacity(digest.len() * 2);
128        const HEX: &[u8; 16] = b"0123456789abcdef";
129        for byte in digest {
130            hash.push(char::from(HEX[usize::from(byte >> 4)]));
131            hash.push(char::from(HEX[usize::from(byte & 0x0f)]));
132        }
133
134        Ok(PolicySnapshot {
135            set,
136            version: PolicyVersion {
137                hash: hash.into(),
138                loaded_at: humantime::format_rfc3339(SystemTime::now())
139                    .to_string()
140                    .into(),
141            },
142            permit_policies,
143            forbid_policy_ids,
144            schema,
145        })
146    }
147
148    fn policy_set(&self) -> &PolicySet {
149        &self.set
150    }
151
152    fn version(&self) -> PolicyVersion {
153        self.version.clone()
154    }
155
156    fn schema(&self) -> Option<&Schema> {
157        self.schema.as_deref()
158    }
159}
160
161/// Extract all permit policies from the Cedar authorization result.
162#[inline]
163fn extract_permit_policies(
164    snapshot: &PolicySnapshot,
165    result: &cedar_policy::Response,
166) -> PermitPolicies {
167    if result.decision() != cedar_policy::Decision::Allow {
168        return PermitPolicies::empty();
169    }
170
171    result
172        .diagnostics()
173        .reason()
174        .filter_map(|reason| snapshot.permit_policies.get(reason))
175        .cloned()
176        .collect()
177}
178
179/// Extract matched forbid policy IDs from a Cedar authorization result.
180#[inline]
181fn extract_forbid_policy_ids(
182    snapshot: &PolicySnapshot,
183    result: &cedar_policy::Response,
184) -> Vec<String> {
185    if result.decision() != cedar_policy::Decision::Deny {
186        return Vec::new();
187    }
188
189    let mut ids: Vec<String> = result
190        .diagnostics()
191        .reason()
192        .filter_map(|reason| snapshot.forbid_policy_ids.get(reason).cloned())
193        .collect();
194    ids.sort();
195    ids.dedup();
196    ids
197}
198
199/// Iterate over groups from a request principal.
200#[inline]
201fn request_groups(request: &Request) -> Option<&Groups> {
202    match &request.principal {
203        Principal::User(user) => Some(user.groups()),
204        Principal::Group(_) => None,
205    }
206}
207
208/// Apply label augmentations to a resource.
209#[inline]
210fn apply_labels(
211    registry: &LabelRegistry,
212    resource: &crate::types::Resource,
213    timers: &mut EvalTimers,
214) -> Option<crate::types::Resource> {
215    let measure_enabled = timers.measure_enabled;
216    let _timer = PhaseTimer::new_if(&mut timers.labels, measure_enabled);
217    #[cfg(feature = "observability")]
218    let _label_span = info_span!("apply_labels").entered();
219    registry.apply_to_clone_if_applicable(resource)
220}
221
222/// Build the Cedar request context from the optional typed context.
223#[inline]
224fn build_effective_context(
225    request_context: Option<&RequestContext>,
226) -> Result<cedar_policy::Context, PolicyError> {
227    match request_context {
228        Some(context) if !context.is_empty() => context.to_cedar_context(),
229        _ => Ok(cedar_policy::Context::empty()),
230    }
231}
232
233/// Build a Cedar request from the authorization request and resource.
234/// UIDs should be pre-converted to avoid redundant conversions.
235#[inline]
236fn build_cedar_req(
237    principal_uid: cedar_policy::EntityUid,
238    action_uid: cedar_policy::EntityUid,
239    resource_uid: cedar_policy::EntityUid,
240    context: cedar_policy::Context,
241    schema: Option<&Schema>,
242    timers: &mut EvalTimers,
243) -> Result<CedarRequest, PolicyError> {
244    let measure_enabled = timers.measure_enabled;
245    let _timer = PhaseTimer::new_if(&mut timers.construct_req, measure_enabled);
246    #[cfg(feature = "observability")]
247    let _req_span = info_span!("construct_cedar_req").entered();
248
249    Ok(CedarRequest::new(
250        principal_uid,
251        action_uid,
252        resource_uid,
253        context,
254        schema,
255    )?)
256}
257
258/// Build Cedar entities for the principal, resource, and groups.
259/// UIDs should be pre-converted to avoid redundant conversions.
260#[inline]
261fn build_entities(
262    principal_uid: cedar_policy::EntityUid,
263    resource_uid: cedar_policy::EntityUid,
264    resource: &crate::types::Resource,
265    groups: Option<&Groups>,
266    schema: Option<&Schema>,
267    timers: &mut EvalTimers,
268) -> Result<Entities, PolicyError> {
269    let group_uids = {
270        let measure_enabled = timers.measure_enabled;
271        let _timer = PhaseTimer::new_if(&mut timers.groups, measure_enabled);
272        #[cfg(feature = "observability")]
273        let _groups_span = info_span!("resolve_groups").entered();
274
275        let mut group_uids = HashSet::with_capacity(groups.map_or(0, Groups::len));
276        if let Some(groups) = groups {
277            for group in groups {
278                group_uids.insert(group.cedar_entity_uid()?);
279            }
280        }
281        group_uids
282    };
283
284    // Group resolution is deliberately measured outside this phase so phase
285    // totals are non-overlapping.
286    let entities = {
287        let measure_enabled = timers.measure_enabled;
288        let _timer = PhaseTimer::new_if(&mut timers.entities, measure_enabled);
289        #[cfg(feature = "observability")]
290        let _entity_span = info_span!("construct_entities").entered();
291
292        // Construct resource entity
293        let resource_attrs = resource.cedar_attr()?;
294        let resource_entity =
295            cedar_policy::Entity::new(resource_uid, resource_attrs, Default::default())?;
296
297        // Construct group entities before moving the parent set into the
298        // principal. This avoids cloning the complete HashSet allocation.
299        let mut all_entities = Vec::with_capacity(group_uids.len() + 2);
300        all_entities.extend(group_uids.iter().cloned().map(Entity::with_uid));
301
302        // Construct principal entity with groups as parents, then batch all
303        // entities into a single call. Entity order has no Cedar semantics.
304        let principal_entity = Entity::new(principal_uid, HashMap::new(), group_uids)?;
305        all_entities.push(principal_entity);
306        all_entities.push(resource_entity);
307
308        // Combine all entities in a single call to reduce overhead
309        Entities::empty().add_entities(all_entities, schema)?
310    };
311
312    if timers.debug_enabled {
313        debug!(
314            event = "Request",
315            phase = "Entities",
316            time = timers.entities.as_micros(),
317            entity_count = entities.iter().count()
318        );
319    }
320
321    Ok(entities)
322}
323
324/// The main engine handle. Thread-safe and cheaply cloneable.
325///
326/// Cloning is cheap (just increments Arc refcounts), but for multithreaded
327/// applications, wrapping in `Arc<PolicyEngine>` and using `Arc::clone()`
328/// is more idiomatic and makes ownership clearer.
329///
330/// For single-threaded use or when passing the engine to a single thread,
331/// you can simply clone it directly.
332#[derive(Clone)]
333pub struct PolicyEngine {
334    /// Shared pointer to an `ArcSwap` holding the current policy snapshot.
335    inner: Arc<ArcSwap<PolicySnapshot>>,
336    /// Optional label registry for augmenting resources with derived attributes.
337    label_registry: Option<Arc<LabelRegistry>>,
338}
339
340impl From<PolicyEngine> for PolicyVersion {
341    fn from(engine: PolicyEngine) -> Self {
342        engine.current_version()
343    }
344}
345
346impl From<&PolicyEngine> for PolicyVersion {
347    fn from(engine: &PolicyEngine) -> Self {
348        engine.current_version()
349    }
350}
351
352impl PolicyEngine {
353    pub fn new_from_str(policy_text: &str) -> Result<Self, PolicyError> {
354        let snapshot: Snapshot = Arc::new(PolicySnapshot::from_policy_text(policy_text)?);
355        Ok(PolicyEngine {
356            inner: Arc::new(ArcSwap::from(snapshot)),
357            label_registry: None,
358        })
359    }
360
361    /// Create a new policy engine with schema-based policy and request validation.
362    pub fn new_from_str_with_schema(
363        policy_text: &str,
364        schema: Schema,
365    ) -> Result<Self, PolicyError> {
366        let snapshot: Snapshot = Arc::new(PolicySnapshot::from_policy_text_with_schema(
367            policy_text,
368            Some(Arc::new(schema)),
369        )?);
370        Ok(PolicyEngine {
371            inner: Arc::new(ArcSwap::from(snapshot)),
372            label_registry: None,
373        })
374    }
375
376    /// Create a new policy engine from policy text and Cedar schema text.
377    pub fn new_from_str_with_cedarschema(
378        policy_text: &str,
379        schema_text: &str,
380    ) -> Result<Self, PolicyError> {
381        let schema: Schema = schema_text
382            .parse()
383            .map_err(|e| PolicyError::ParseError(format!("failed to parse Cedar schema: {e}")))?;
384        Self::new_from_str_with_schema(policy_text, schema)
385    }
386
387    /// Create a new policy engine with a label registry.
388    ///
389    /// This is a convenience method that combines `new_from_str` and `with_label_registry`.
390    pub fn with_label_registry(mut self, registry: LabelRegistry) -> Self {
391        self.label_registry = Some(Arc::new(registry));
392        self
393    }
394
395    /// Set or replace the label registry for this engine.
396    ///
397    /// This allows updating the labelers after the engine has been created.
398    pub fn set_label_registry(&mut self, registry: LabelRegistry) {
399        self.label_registry = Some(Arc::new(registry));
400    }
401
402    /// Get a reference to the label registry, if one is configured.
403    pub fn label_registry(&self) -> Option<&LabelRegistry> {
404        self.label_registry.as_deref()
405    }
406
407    pub fn reload_from_str(&self, policy_text: &str) -> Result<(), PolicyError> {
408        let current_snapshot = self.current_snapshot();
409        let had_schema = current_snapshot.schema.is_some();
410        let schema = current_snapshot.schema.clone();
411        let new_snapshot: Snapshot = Arc::new(PolicySnapshot::from_policy_text_with_schema(
412            policy_text,
413            schema,
414        )?);
415        self.inner.store(new_snapshot);
416        debug!(
417            event = "PolicyReload",
418            schema_enabled = had_schema,
419            schema_reloaded = false
420        );
421        // Track reloads for metrics (no-op if feature disabled or no sink configured)
422        #[cfg(feature = "observability")]
423        record_reload();
424        Ok(())
425    }
426
427    /// Reload policies and replace the engine schema at the same time.
428    pub fn reload_from_str_with_schema(
429        &self,
430        policy_text: &str,
431        schema: Schema,
432    ) -> Result<(), PolicyError> {
433        let had_schema = self.current_snapshot().schema.is_some();
434        let new_snapshot: Snapshot = Arc::new(PolicySnapshot::from_policy_text_with_schema(
435            policy_text,
436            Some(Arc::new(schema)),
437        )?);
438        self.inner.store(new_snapshot);
439        debug!(
440            event = "PolicyReload",
441            schema_enabled = true,
442            schema_reloaded = true,
443            schema_previously_enabled = had_schema
444        );
445        // Track reloads for metrics (no-op if feature disabled or no sink configured)
446        #[cfg(feature = "observability")]
447        record_reload();
448        Ok(())
449    }
450
451    /// Reload policies and replace the engine schema from Cedar schema text.
452    pub fn reload_from_str_with_cedarschema(
453        &self,
454        policy_text: &str,
455        schema_text: &str,
456    ) -> Result<(), PolicyError> {
457        let schema: Schema = schema_text
458            .parse()
459            .map_err(|e| PolicyError::ParseError(format!("failed to parse Cedar schema: {e}")))?;
460        self.reload_from_str_with_schema(policy_text, schema)
461    }
462
463    /// Get the current immutable snapshot.
464    fn current_snapshot(&self) -> Snapshot {
465        self.inner.load_full()
466    }
467
468    /// Get the current policy version.
469    ///
470    /// The `hash` is computed from the policy text, and `loaded_at` reflects
471    /// when this snapshot was installed.
472    pub fn current_version(&self) -> PolicyVersion {
473        self.current_snapshot().version()
474    }
475
476    /// Prepare a request for authorization: accumulate labels, build Cedar entities, resolve groups.
477    ///
478    /// This separates request preparation from the authorization decision, making both
479    /// more testable and the main hot path more readable.
480    fn prepare(
481        &self,
482        request: &Request,
483        request_context: Option<&RequestContext>,
484    ) -> Result<PreparedRequest, PolicyError> {
485        let snapshot = self.current_snapshot();
486        let schema = snapshot.schema();
487        #[cfg(feature = "observability")]
488        let sink = get_sink();
489        #[cfg(feature = "observability")]
490        let metrics_enabled = metrics_enabled(&sink);
491        #[cfg(not(feature = "observability"))]
492        let metrics_enabled = false;
493        let debug_enabled = tracing::enabled!(tracing::Level::DEBUG);
494        let mut timers = EvalTimers::start(debug_enabled || metrics_enabled, debug_enabled);
495
496        let groups = request_groups(request);
497
498        if timers.debug_enabled {
499            debug!(
500                event = "Request",
501                phase = "Evaluation",
502                group_count = groups.map_or(0, Groups::len)
503            );
504        }
505
506        // Convert each UID once after trusted label derivation is complete.
507        let principal_uid = request.principal.cedar_entity_uid()?;
508        let action_uid = request.action.cedar_entity_uid()?;
509
510        let labelled_resource = if let Some(registry) = &self.label_registry {
511            let labelled_resource = apply_labels(registry, &request.resource, &mut timers);
512            if timers.debug_enabled {
513                let resource_for_metrics = labelled_resource.as_ref().unwrap_or(&request.resource);
514                debug!(
515                    event = "Request",
516                    phase = "LabelsApplied",
517                    time = timers.labels.as_micros(),
518                    attribute_count = resource_for_metrics.attributes().len()
519                );
520            }
521            labelled_resource
522        } else {
523            if timers.debug_enabled {
524                debug!(
525                    event = "Request",
526                    phase = "LabelsApplied",
527                    time = timers.labels.as_micros()
528                );
529            }
530            None
531        };
532        let resource_for_entities = labelled_resource.as_ref().unwrap_or(&request.resource);
533        let resource_uid = resource_for_entities.cedar_entity_uid()?;
534        let context = build_effective_context(request_context)?;
535
536        if timers.debug_enabled {
537            debug!(
538                event = "Request",
539                phase = "Parsed",
540                group_count = groups.map_or(0, Groups::len),
541                attribute_count = resource_for_entities.attributes().len(),
542                request_context_attribute_count = request_context.map_or(0, RequestContext::len)
543            );
544        }
545
546        // The request and entity graph both own the same principal and resource
547        // IDs. Clone each UID once, then move both copies into Cedar instead of
548        // cloning again inside both builders.
549        let principal_uid_for_entities = principal_uid.clone();
550        let resource_uid_for_entities = resource_uid.clone();
551
552        // Build Cedar request with pre-converted UIDs
553        let cedar_req = build_cedar_req(
554            principal_uid,
555            action_uid,
556            resource_uid,
557            context,
558            schema,
559            &mut timers,
560        )?;
561
562        // Build entities with pre-converted UIDs and potentially-modified resource
563        let entities = build_entities(
564            principal_uid_for_entities,
565            resource_uid_for_entities,
566            resource_for_entities,
567            groups,
568            schema,
569            &mut timers,
570        )?;
571
572        if timers.debug_enabled {
573            debug!(
574                event = "Request",
575                phase = "GroupsResolved",
576                time = timers.groups.as_micros(),
577            );
578        }
579
580        Ok(PreparedRequest {
581            cedar_req,
582            entities,
583            snapshot,
584            timers,
585            #[cfg(feature = "observability")]
586            sink,
587            #[cfg(feature = "observability")]
588            metrics_enabled,
589        })
590    }
591
592    /// Evaluate a policy request against the currently loaded policy set.
593    ///
594    /// This method performs a complete Cedar policy evaluation:
595    /// 1. Applies any registered labelers to augment resource attributes
596    /// 2. Constructs Cedar entities for the principal (including groups), action, and resource
597    /// 3. Executes the Cedar authorization decision
598    /// 4. Returns either `Allow` (with the matching policy) or `Deny`, both including version metadata
599    ///
600    /// # Arguments
601    ///
602    /// * `request` - The authorization request containing the principal, action, and resource
603    ///
604    /// # Returns
605    ///
606    /// * `Ok(Decision::Allow)` - If at least one permit policy matches and no forbid policies match
607    /// * `Ok(Decision::Deny)` - If no permit policies match or if a forbid policy matches
608    /// * `Err(PolicyError)` - If there's an error constructing entities, parsing the request, or during evaluation
609    ///
610    /// # Examples
611    ///
612    /// ```rust
613    /// use treetop_core::{PolicyEngine, Request, Principal, User, Action, Resource, Decision};
614    ///
615    /// let policies = r#"
616    ///     permit (
617    ///         principal == User::"alice",
618    ///         action == Action::"read",
619    ///         resource == Document::"doc1"
620    ///     );
621    /// "#;
622    ///
623    /// let engine = PolicyEngine::new_from_str(policies).unwrap();
624    ///
625    /// let request = Request {
626    ///     principal: Principal::User(User::new("alice", None, None)),
627    ///     action: Action::new("read", None),
628    ///     resource: Resource::new("Document", "doc1"),
629    /// };
630    ///
631    /// let decision = engine.evaluate(&request).unwrap();
632    /// assert!(matches!(decision, Decision::Allow { .. }));
633    ///
634    /// // Access version information
635    /// if let Decision::Allow { version, .. } = decision {
636    ///     println!("Allowed by policy version: {}", version.hash);
637    /// }
638    /// ```
639    ///
640    /// # Thread Safety
641    ///
642    /// This method is thread-safe and lock-free. Multiple threads can evaluate requests
643    /// concurrently without blocking each other.
644    #[cfg_attr(
645        feature = "observability",
646        tracing::instrument(name = "policy_evaluation", skip_all)
647    )]
648    pub fn evaluate(&self, request: &Request) -> Result<Decision, PolicyError> {
649        Ok(self.evaluate_internal(request, None, false)?.decision)
650    }
651
652    /// Evaluate a request with explicit Cedar request context.
653    pub fn evaluate_with_context(
654        &self,
655        request: &Request,
656        request_context: &RequestContext,
657    ) -> Result<Decision, PolicyError> {
658        Ok(self
659            .evaluate_internal(request, Some(request_context), false)?
660            .decision)
661    }
662
663    /// Evaluate a request and include deny-side forbid diagnostics.
664    pub fn evaluate_with_diagnostics(
665        &self,
666        request: &Request,
667    ) -> Result<DecisionDiagnostics, PolicyError> {
668        self.evaluate_internal(request, None, true)
669    }
670
671    /// Evaluate a request with explicit context and include deny diagnostics.
672    pub fn evaluate_with_context_and_diagnostics(
673        &self,
674        request: &Request,
675        request_context: &RequestContext,
676    ) -> Result<DecisionDiagnostics, PolicyError> {
677        self.evaluate_internal(request, Some(request_context), true)
678    }
679
680    fn evaluate_internal(
681        &self,
682        request: &Request,
683        request_context: Option<&RequestContext>,
684        include_forbid_diagnostics: bool,
685    ) -> Result<DecisionDiagnostics, PolicyError> {
686        // Prepare the request: apply labels, build entities, resolve groups
687        let mut prepared = self.prepare(request, request_context)?;
688
689        // Perform authorization with RAII timing (using cached Authorizer)
690        let result = {
691            let measure_enabled = prepared.timers.measure_enabled;
692            let _timer = PhaseTimer::new_if(&mut prepared.timers.authz, measure_enabled);
693            #[cfg(feature = "observability")]
694            let _authz_span = info_span!("authorize").entered();
695            get_authorizer().is_authorized(
696                &prepared.cedar_req,
697                &prepared.snapshot.set,
698                &prepared.entities,
699            )
700        };
701
702        if prepared.timers.debug_enabled {
703            debug!(
704                event = "Request",
705                phase = "Authorized",
706                time = prepared.timers.authz.as_micros(),
707                decision = ?result.decision(),
708            );
709        }
710
711        let version = prepared.snapshot.version();
712        if prepared.timers.debug_enabled {
713            debug!(
714                event = "Request",
715                phase = "Result",
716                time = prepared.timers.total_elapsed().as_micros(),
717                result = ?result.decision(),
718                policy_hash = %version.hash,
719                policy_loaded_at = %version.loaded_at,
720            );
721        }
722
723        // Permit metadata is part of Allow decisions. Forbid IDs are only
724        // materialized when the caller explicitly requests diagnostics. Metrics
725        // borrow matching IDs from the Cedar response and compiled snapshot.
726        let permit_policies = extract_permit_policies(&prepared.snapshot, &result);
727        let collect_forbid_ids = include_forbid_diagnostics;
728        let forbid_policy_ids = if collect_forbid_ids {
729            extract_forbid_policy_ids(&prepared.snapshot, &result)
730        } else {
731            Vec::new()
732        };
733        let decision =
734            Decision::from_decision_with_policy(result.decision(), permit_policies, version)?;
735
736        // Record metrics (no-op when no sink is configured or feature disabled)
737        #[cfg(feature = "observability")]
738        {
739            if prepared.metrics_enabled {
740                let dur = prepared.timers.total_elapsed();
741                let allowed = result.decision() == cedar_policy::Decision::Allow;
742                let phases = EvaluationPhases {
743                    apply_labels_ms: prepared.timers.labels.as_secs_f64() * 1000.0,
744                    construct_entities_ms: prepared.timers.entities.as_secs_f64() * 1000.0,
745                    resolve_groups_ms: prepared.timers.groups.as_secs_f64() * 1000.0,
746                    authorize_ms: prepared.timers.authz.as_secs_f64() * 1000.0,
747                    total_ms: dur.as_secs_f64() * 1000.0,
748                };
749                let matched_policies = match &decision {
750                    Decision::Allow { policies, .. } => MatchedPolicySource::Allow(policies),
751                    Decision::Deny { .. } => MatchedPolicySource::Deny {
752                        diagnostics: result.diagnostics(),
753                        policy_ids: &prepared.snapshot.forbid_policy_ids,
754                    },
755                };
756                let observation = EvaluationObservation::new(
757                    dur,
758                    allowed,
759                    &request.action,
760                    phases,
761                    matched_policies,
762                );
763
764                record_evaluation_observation(&prepared.sink, &observation);
765            }
766        }
767
768        Ok(DecisionDiagnostics {
769            decision,
770            matched_forbid_policy_ids: forbid_policy_ids,
771        })
772    }
773
774    /// List permit-policy candidates whose scope matches a user.
775    ///
776    /// This mirrors [`PolicyEngine::evaluate`] input shape for principal identity:
777    /// user id + groups + shared namespace.
778    ///
779    /// Matching includes all Cedar principal-constraint forms:
780    /// - `principal == User::"..."`
781    /// - `principal in Group::"..."`
782    /// - `principal`
783    /// - `principal is User`
784    /// - `principal is User in Group::"..."`
785    ///
786    /// Cedar `when` and `unless` clauses are not evaluated. The result is not
787    /// an authorization decision; use [`PolicyEngine::evaluate`] to authorize.
788    /// Resource constraints are not applied in this method. To additionally
789    /// filter by policy resource constraints, use
790    /// [`PolicyEngine::list_policies_for_user_with_resource`].
791    ///
792    /// Output is deterministic: policies are sorted by Cedar policy ID.
793    /// Each returned policy includes match reasons via `UserPolicies::matches()`.
794    ///
795    /// # Arguments
796    ///
797    /// * `user` - User ID
798    /// * `groups` - Group IDs the user belongs to
799    /// * `namespace` - Optional shared namespace path for both user and groups
800    ///
801    /// # Returns
802    ///
803    /// * `Ok(UserPolicies)` - Matching policies and match metadata
804    /// * `Err(PolicyError)` - If entity UID construction fails
805    ///
806    /// # Examples
807    ///
808    /// ```rust
809    /// use treetop_core::PolicyEngine;
810    ///
811    /// let policies = r#"
812    ///     permit (principal == User::"alice", action, resource);
813    ///     permit (principal in Group::"admins", action, resource);
814    /// "#;
815    ///
816    /// let engine = PolicyEngine::new_from_str(policies).unwrap();
817    /// let user_policies = engine.list_policies_for_user("alice", &["admins"], &[]).unwrap();
818    ///
819    /// assert_eq!(user_policies.policies().len(), 2);
820    /// assert!(!user_policies.matches().is_empty());
821    /// ```
822    pub fn list_policies_for_user(
823        &self,
824        user: &str,
825        groups: &[&str],
826        namespace: &[&str],
827    ) -> Result<UserPolicies, PolicyError> {
828        self.list_policies_for_user_with_resource_and_effect(
829            user,
830            groups,
831            namespace,
832            None,
833            PolicyEffectFilter::Permit,
834        )
835    }
836
837    /// List permit-policy candidates whose scope matches a concrete request.
838    ///
839    /// This mirrors [`PolicyEngine::evaluate`] by accepting `&Request` and uses:
840    /// - the request principal (including user group membership, if any)
841    /// - the request action
842    /// - the request resource
843    ///
844    /// Cedar `when` and `unless` clauses are not evaluated. The result is not
845    /// an authorization decision. This method defaults to permit policies; use
846    /// [`PolicyEngine::list_policies_with_effect`] for an explicit effect.
847    pub fn list_policies(&self, request: &Request) -> Result<UserPolicies, PolicyError> {
848        self.list_policies_with_effect(request, PolicyEffectFilter::Permit)
849    }
850
851    /// List policy candidates for a request, with explicit effect filtering.
852    ///
853    /// Cedar `when` and `unless` clauses are not evaluated.
854    pub fn list_policies_with_effect(
855        &self,
856        request: &Request,
857        effect_filter: PolicyEffectFilter,
858    ) -> Result<UserPolicies, PolicyError> {
859        let principal = PrincipalQuery::from_principal(&request.principal)?;
860        let action = ActionQuery::from_action(&request.action)?;
861        self.list_policies_dispatch(
862            &request.principal.to_string(),
863            &principal,
864            Some(&action),
865            Some(&request.resource),
866            effect_filter,
867        )
868    }
869
870    /// List all policies applicable to a user, optionally filtering by resource constraints.
871    ///
872    /// This variant applies both principal and resource constraints:
873    /// - principal constraints as described in [`PolicyEngine::list_policies_for_user`]
874    /// - resource constraints (`==`, `in`, `is`, `is in`, `any`) when `resource` is provided
875    ///
876    /// When `resource` is `None`, behavior is equivalent to
877    /// [`PolicyEngine::list_policies_for_user`].
878    ///
879    /// Returned `UserPolicies` includes match reasons for principal and, when
880    /// applicable, resource matches.
881    pub fn list_policies_for_user_with_resource(
882        &self,
883        user: &str,
884        groups: &[&str],
885        namespace: &[&str],
886        resource: Option<&Resource>,
887    ) -> Result<UserPolicies, PolicyError> {
888        self.list_policies_for_user_with_resource_and_effect(
889            user,
890            groups,
891            namespace,
892            resource,
893            PolicyEffectFilter::Permit,
894        )
895    }
896
897    /// List all policies applicable to a user with optional resource and effect filtering.
898    pub fn list_policies_for_user_with_resource_and_effect(
899        &self,
900        user: &str,
901        groups: &[&str],
902        namespace: &[&str],
903        resource: Option<&Resource>,
904        effect_filter: PolicyEffectFilter,
905    ) -> Result<UserPolicies, PolicyError> {
906        let principal = PrincipalQuery::for_user(user, groups, namespace)?;
907        self.list_policies_dispatch(user, &principal, None, resource, effect_filter)
908    }
909
910    /// List all policies applicable to a group principal.
911    ///
912    /// Useful when callers model group identities directly as principals
913    /// (mirroring `Principal::Group` in [`PolicyEngine::evaluate`]).
914    ///
915    /// Resource constraints are not applied in this method. To also filter by
916    /// resource constraints, use
917    /// [`PolicyEngine::list_policies_for_group_with_resource`].
918    pub fn list_policies_for_group(
919        &self,
920        group: &str,
921        namespace: &[&str],
922    ) -> Result<UserPolicies, PolicyError> {
923        self.list_policies_for_group_with_resource_and_effect(
924            group,
925            namespace,
926            None,
927            PolicyEffectFilter::Permit,
928        )
929    }
930
931    /// List all policies applicable to a group principal, optionally filtering by resource constraints.
932    ///
933    /// This applies principal constraints for a group principal and, when
934    /// `resource` is provided, resource constraints as well.
935    pub fn list_policies_for_group_with_resource(
936        &self,
937        group: &str,
938        namespace: &[&str],
939        resource: Option<&Resource>,
940    ) -> Result<UserPolicies, PolicyError> {
941        self.list_policies_for_group_with_resource_and_effect(
942            group,
943            namespace,
944            resource,
945            PolicyEffectFilter::Permit,
946        )
947    }
948
949    /// List all policies applicable to a group principal with optional resource and effect filtering.
950    pub fn list_policies_for_group_with_resource_and_effect(
951        &self,
952        group: &str,
953        namespace: &[&str],
954        resource: Option<&Resource>,
955        effect_filter: PolicyEffectFilter,
956    ) -> Result<UserPolicies, PolicyError> {
957        let principal = PrincipalQuery::for_group(group, namespace)?;
958        self.list_policies_dispatch(group, &principal, None, resource, effect_filter)
959    }
960
961    fn list_policies_dispatch(
962        &self,
963        principal_id: &str,
964        principal: &PrincipalQuery,
965        action: Option<&ActionQuery>,
966        resource: Option<&Resource>,
967        effect_filter: PolicyEffectFilter,
968    ) -> Result<UserPolicies, PolicyError> {
969        let snapshot = self.current_snapshot();
970        let policies = snapshot.set.policies();
971        let resource_query = match resource {
972            Some(resource) => Some(ResourceQuery::from_resource(resource)?),
973            None => None,
974        };
975        let mut matching_policies: Vec<(Policy, Vec<PolicyMatchReason>)> = Vec::new();
976
977        for policy in policies {
978            if !matches_effect(policy.effect(), effect_filter) {
979                continue;
980            }
981
982            let Some(principal_reason) =
983                principal_match_reason(policy.principal_constraint(), principal)
984            else {
985                continue;
986            };
987
988            let Some(action_reason) = action_match_reason(policy.action_constraint(), action)
989            else {
990                continue;
991            };
992
993            let Some(resource_reason) =
994                resource_match_reason(policy.resource_constraint(), resource_query.as_ref())
995            else {
996                continue;
997            };
998
999            let mut reasons = vec![principal_reason];
1000            if let Some(action_reason) = action_reason {
1001                reasons.push(action_reason);
1002            }
1003            if let Some(resource_reason) = resource_reason {
1004                reasons.push(resource_reason);
1005            }
1006
1007            matching_policies.push((policy.clone(), reasons));
1008        }
1009
1010        Ok(UserPolicies::new_with_matches(
1011            principal_id,
1012            matching_policies,
1013        ))
1014    }
1015
1016    pub fn policies(&self) -> Result<Vec<Policy>, PolicyError> {
1017        let snapshot = self.current_snapshot();
1018        Ok(snapshot.policy_set().policies().cloned().collect())
1019    }
1020}
1021
1022#[cfg(test)]
1023mod tests;