Skip to main content

authz_core/
core_resolver.rs

1//! CoreResolver - implements CheckResolver by walking the authorization model.
2
3use async_trait::async_trait;
4use std::collections::HashMap;
5use std::sync::Arc;
6use tokio::sync::Semaphore;
7
8use crate::cache::{AuthzCache, noop_cache};
9use crate::error::AuthzError;
10use crate::model_ast::{AssignableTarget, RelationExpr};
11use crate::policy_provider::PolicyProvider;
12use crate::resolver::{CheckResolver, CheckResult, ResolveCheckRequest};
13use crate::traits::{Tuple, TupleFilter, TupleReader};
14
15/// Convert JSON context values to authz-cel Value types.
16fn json_context_to_cel(
17    context: &HashMap<String, serde_json::Value>,
18) -> HashMap<String, crate::cel::Value> {
19    let mut cel_ctx = HashMap::new();
20    for (key, value) in context {
21        if let Some(cel_val) = json_value_to_cel(value) {
22            cel_ctx.insert(key.clone(), cel_val);
23        }
24    }
25    cel_ctx
26}
27
28fn json_value_to_cel(value: &serde_json::Value) -> Option<crate::cel::Value> {
29    match value {
30        serde_json::Value::Bool(b) => Some(crate::cel::Value::Bool(*b)),
31        serde_json::Value::Number(n) => n.as_i64().map(crate::cel::Value::Int),
32        serde_json::Value::String(s) => Some(crate::cel::Value::String(s.clone())),
33        serde_json::Value::Array(arr) => {
34            let items: Vec<crate::cel::Value> = arr.iter().filter_map(json_value_to_cel).collect();
35            Some(crate::cel::Value::List(items))
36        }
37        _ => None,
38    }
39}
40
41/// Check optimization strategy.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum CheckStrategy {
44    /// Batch queries together (default, best for most cases)
45    #[default]
46    Batch,
47    /// Parallel evaluation (useful for high-latency datastores)
48    Parallel,
49}
50
51/// Core resolver that walks the authorization model to resolve checks.
52///
53/// # Caching
54///
55/// CoreResolver uses the `AuthzCache` trait for two cache layers:
56/// - L2 result cache: Caches check results (disabled for contextual tuples)
57/// - L3 tuple cache: Caches tuple reads (disabled for contextual tuples)
58///
59/// By default both caches are `NoopCache` (disabled).  Callers can inject
60/// cross-request caches via `.with_result_cache()` / `.with_tuple_cache()`.
61pub struct CoreResolver<D, P> {
62    datastore: D,
63    policy_provider: P,
64    max_concurrent: usize,
65    /// Semaphore limiting concurrent datastore reads
66    read_semaphore: Arc<Semaphore>,
67    /// L2: Cache for check results (key: object:relation:subject)
68    result_cache: Arc<dyn AuthzCache<CheckResult>>,
69    /// L3: Cache for tuple reads (key: object_type:object_id:relation)
70    tuple_cache: Arc<dyn AuthzCache<Vec<Tuple>>>,
71    /// Check optimization strategy
72    strategy: CheckStrategy,
73}
74
75impl<D, P> CoreResolver<D, P>
76where
77    D: TupleReader + Clone + Send + Sync + 'static,
78    P: PolicyProvider + Send + Sync + 'static,
79{
80    /// Create a new CoreResolver with `NoopCache` (caching disabled by default).
81    pub fn new(datastore: D, policy_provider: P) -> Self {
82        Self {
83            datastore,
84            policy_provider,
85            max_concurrent: 50, // Default concurrency limit
86            read_semaphore: Arc::new(Semaphore::new(50)),
87            result_cache: noop_cache(),
88            tuple_cache: noop_cache(),
89            strategy: CheckStrategy::default(),
90        }
91    }
92
93    /// Set the check strategy.
94    pub fn with_strategy(mut self, strategy: CheckStrategy) -> Self {
95        self.strategy = strategy;
96        self
97    }
98
99    /// Set the maximum concurrent dispatches.
100    pub fn with_max_concurrent(mut self, max: usize) -> Self {
101        self.max_concurrent = max;
102        self.read_semaphore = Arc::new(Semaphore::new(max));
103        self
104    }
105
106    /// Set the L2 dispatch-result cache.
107    pub fn with_result_cache(mut self, cache: Arc<dyn AuthzCache<CheckResult>>) -> Self {
108        self.result_cache = cache;
109        self
110    }
111
112    /// Set the L3 tuple-iterator cache.
113    pub fn with_tuple_cache(mut self, cache: Arc<dyn AuthzCache<Vec<Tuple>>>) -> Self {
114        self.tuple_cache = cache;
115        self
116    }
117
118    fn result_cache_key(request: &ResolveCheckRequest) -> String {
119        if request.at_revision.is_empty() {
120            // Fallback for tests/legacy: no revision prefix
121            format!(
122                "{}:{}:{}:{}:{}",
123                request.object_type,
124                request.object_id,
125                request.relation,
126                request.subject_type,
127                request.subject_id
128            )
129        } else {
130            format!(
131                "{}:{}:{}:{}:{}:{}",
132                request.at_revision,
133                request.object_type,
134                request.object_id,
135                request.relation,
136                request.subject_type,
137                request.subject_id
138            )
139        }
140    }
141
142    fn tuple_cache_key(
143        revision: &str,
144        object_type: &str,
145        object_id: &str,
146        relation: &str,
147    ) -> String {
148        if revision.is_empty() {
149            format!("{}:{}:{}", object_type, object_id, relation)
150        } else {
151            format!("{}:{}:{}:{}", revision, object_type, object_id, relation)
152        }
153    }
154
155    /// Resolve a check by walking the relation expression.
156    fn resolve_relation_expr<'a>(
157        &'a self,
158        expr: &'a RelationExpr,
159        request: &'a ResolveCheckRequest,
160    ) -> std::pin::Pin<
161        Box<dyn std::future::Future<Output = Result<CheckResult, AuthzError>> + Send + 'a>,
162    > {
163        Box::pin(async move {
164            let expr_type = match expr {
165                RelationExpr::DirectAssignment(_) => "direct",
166                RelationExpr::ComputedUserset(_) => "computed_userset",
167                RelationExpr::TupleToUserset { .. } => "tuple_to_userset",
168                RelationExpr::Union(_) => "union",
169                RelationExpr::Intersection(_) => "intersection",
170                RelationExpr::Exclusion { .. } => "exclusion",
171            };
172            tracing::debug!(expr_type = expr_type, "resolve_expr");
173            match expr {
174                RelationExpr::DirectAssignment(targets) => {
175                    self.resolve_direct(targets, request).await
176                }
177                RelationExpr::ComputedUserset(target_relation) => {
178                    self.resolve_computed_userset(target_relation, request)
179                        .await
180                }
181                RelationExpr::TupleToUserset {
182                    tupleset,
183                    computed_userset,
184                } => {
185                    self.resolve_tuple_to_userset(tupleset, computed_userset, request)
186                        .await
187                }
188                RelationExpr::Union(exprs) => self.resolve_union(exprs, request).await,
189                RelationExpr::Intersection(exprs) => {
190                    self.resolve_intersection(exprs, request).await
191                }
192                RelationExpr::Exclusion { base, subtract } => {
193                    self.resolve_exclusion(base, subtract, request).await
194                }
195            }
196        })
197    }
198
199    /// Handle DirectAssignment: [user, group#member, user:*]
200    async fn resolve_direct(
201        &self,
202        targets: &[AssignableTarget],
203        request: &ResolveCheckRequest,
204    ) -> Result<CheckResult, AuthzError> {
205        // Read tuples for this object and relation
206        let tuples = self
207            .read_tuples_with_contextual(
208                &request.object_type,
209                &request.object_id,
210                &request.relation,
211                request,
212            )
213            .await?;
214
215        tracing::info!(
216            object_type = %request.object_type,
217            object_id = %request.object_id,
218            relation = %request.relation,
219            subject_type = %request.subject_type,
220            subject_id = %request.subject_id,
221            tuples = ?tuples,
222            targets = ?targets,
223            "resolve_direct input"
224        );
225
226        // Check each tuple against the assignable targets
227        for tuple in &tuples {
228            for target in targets {
229                match target {
230                    AssignableTarget::Type(type_name) => {
231                        // Direct type match: viewer: [user]
232                        if tuple.subject_type == *type_name
233                            && tuple.subject_id == request.subject_id
234                        {
235                            return Ok(CheckResult::Allowed);
236                        }
237                    }
238                    AssignableTarget::Userset {
239                        type_name,
240                        relation,
241                    } => {
242                        // Userset expansion: viewer: [group#member]
243                        // The tuple subject_id may contain a #relation suffix
244                        // (e.g. "eng#member") which must be stripped to get the
245                        // bare object ID for the child check dispatch.
246                        let bare_subject_id = tuple
247                            .subject_id
248                            .split('#')
249                            .next()
250                            .unwrap_or(&tuple.subject_id)
251                            .to_string();
252
253                        tracing::info!(
254                            object_type = %request.object_type,
255                            object_id = %request.object_id,
256                            request_relation = %request.relation,
257                            tuple_subject_type = %tuple.subject_type,
258                            tuple_subject_id = %tuple.subject_id,
259                            bare_subject_id = %bare_subject_id,
260                            target_type = %type_name,
261                            target_relation = %relation,
262                            subject_type = %request.subject_type,
263                            subject_id = %request.subject_id,
264                            "Evaluating userset target"
265                        );
266
267                        if tuple.subject_type == *type_name {
268                            // Dispatch check: group:admins#member@user:alice
269                            let child_req = request.child_request(
270                                type_name.clone(),
271                                bare_subject_id,
272                                relation.clone(),
273                                request.subject_type.clone(),
274                                request.subject_id.clone(),
275                            );
276                            let result = self.resolve_check(child_req).await?;
277                            tracing::info!(
278                                target_type = %type_name,
279                                target_object_id = %tuple.subject_id,
280                                target_relation = %relation,
281                                result = ?result,
282                                "Userset child check result"
283                            );
284                            if result == CheckResult::Allowed {
285                                return Ok(CheckResult::Allowed);
286                            }
287                        }
288                    }
289                    AssignableTarget::Wildcard(type_name) => {
290                        // Wildcard: viewer: [user:*]
291                        if tuple.subject_type == *type_name && tuple.subject_id == "*" {
292                            // Any user of this type is allowed
293                            if request.subject_type == *type_name {
294                                return Ok(CheckResult::Allowed);
295                            }
296                        }
297                    }
298                    AssignableTarget::Conditional { target, condition } => {
299                        // Conditional: viewer: [user with ip_check]
300                        // First check if the base target matches
301                        if let AssignableTarget::Type(type_name) = target.as_ref()
302                            && tuple.subject_type == *type_name
303                            && tuple.subject_id == request.subject_id
304                        {
305                            // Check if tuple has the required condition
306                            if let Some(tuple_condition) = &tuple.condition
307                                && tuple_condition == condition
308                            {
309                                // If context is provided, evaluate the CEL condition
310                                if !request.context.is_empty() {
311                                    let type_system = self.policy_provider.get_policy().await?;
312                                    if let Some(cond_def) = type_system.get_condition(condition) {
313                                        let cel_ctx = json_context_to_cel(&request.context);
314                                        match crate::cel::compile(&cond_def.expression) {
315                                            Ok(program) => {
316                                                match crate::cel::evaluate(&program, &cel_ctx) {
317                                                    Ok(crate::cel::CelResult::Met(true)) => {
318                                                        return Ok(CheckResult::Allowed);
319                                                    }
320                                                    Ok(crate::cel::CelResult::Met(false)) => {
321                                                        // Condition not met — continue checking other tuples
322                                                    }
323                                                    Ok(
324                                                        crate::cel::CelResult::MissingParameters(
325                                                            params,
326                                                        ),
327                                                    ) => {
328                                                        return Ok(CheckResult::ConditionRequired(
329                                                            params,
330                                                        ));
331                                                    }
332                                                    Err(e) => {
333                                                        tracing::warn!(condition = %condition, error = %e, "CEL evaluation error");
334                                                        return Ok(CheckResult::ConditionRequired(
335                                                            vec![condition.clone()],
336                                                        ));
337                                                    }
338                                                }
339                                            }
340                                            Err(e) => {
341                                                tracing::warn!(condition = %condition, error = %e, "CEL compile error");
342                                                return Ok(CheckResult::ConditionRequired(vec![
343                                                    condition.clone(),
344                                                ]));
345                                            }
346                                        }
347                                    } else {
348                                        return Ok(CheckResult::ConditionRequired(vec![
349                                            condition.clone(),
350                                        ]));
351                                    }
352                                } else {
353                                    return Ok(CheckResult::ConditionRequired(vec![
354                                        condition.clone(),
355                                    ]));
356                                }
357                            }
358                        }
359                    }
360                }
361            }
362        }
363
364        Ok(CheckResult::Denied)
365    }
366
367    /// Handle ComputedUserset: viewer (rewrite to same object)
368    async fn resolve_computed_userset(
369        &self,
370        target_relation: &str,
371        request: &ResolveCheckRequest,
372    ) -> Result<CheckResult, AuthzError> {
373        // Rewrite: document:1#can_view → document:1#viewer
374        let child_req = request.child_request(
375            request.object_type.clone(),
376            request.object_id.clone(),
377            target_relation.to_string(),
378            request.subject_type.clone(),
379            request.subject_id.clone(),
380        );
381
382        tracing::info!(
383            object_type = %request.object_type,
384            object_id = %request.object_id,
385            request_relation = %request.relation,
386            target_relation = %target_relation,
387            subject_type = %request.subject_type,
388            subject_id = %request.subject_id,
389            "resolve_computed_userset rewriting to child request"
390        );
391
392        self.resolve_check(child_req).await
393    }
394
395    /// Handle TupleToUserset: viewer from parent
396    async fn resolve_tuple_to_userset(
397        &self,
398        tupleset_relation: &str,
399        computed_relation: &str,
400        request: &ResolveCheckRequest,
401    ) -> Result<CheckResult, AuthzError> {
402        // 1. Read tuples: document:1#parent → [folder:root]
403        let parent_tuples = self
404            .read_tuples_with_contextual(
405                &request.object_type,
406                &request.object_id,
407                tupleset_relation,
408                request,
409            )
410            .await?;
411
412        tracing::info!(
413            object_type = %request.object_type,
414            object_id = %request.object_id,
415            request_relation = %request.relation,
416            tupleset_relation = %tupleset_relation,
417            computed_relation = %computed_relation,
418            parent_tuples = ?parent_tuples,
419            "resolve_tuple_to_userset parent tuples"
420        );
421
422        if parent_tuples.is_empty() {
423            return Ok(CheckResult::Denied);
424        }
425
426        // 2. For each parent, dispatch: folder:root#viewer
427        for parent_tuple in parent_tuples {
428            let child_req = request.child_request(
429                parent_tuple.subject_type.clone(),
430                parent_tuple.subject_id.clone(),
431                computed_relation.to_string(),
432                request.subject_type.clone(),
433                request.subject_id.clone(),
434            );
435
436            let result = self.resolve_check(child_req).await?;
437            tracing::info!(
438                parent_type = %parent_tuple.subject_type,
439                parent_id = %parent_tuple.subject_id,
440                computed_relation = %computed_relation,
441                result = ?result,
442                "resolve_tuple_to_userset child result"
443            );
444            if result == CheckResult::Allowed {
445                return Ok(CheckResult::Allowed);
446            }
447        }
448
449        Ok(CheckResult::Denied)
450    }
451
452    /// Handle Union: [user] or editor or owner
453    async fn resolve_union(
454        &self,
455        exprs: &[RelationExpr],
456        request: &ResolveCheckRequest,
457    ) -> Result<CheckResult, AuthzError> {
458        tracing::info!(
459            object_type = %request.object_type,
460            object_id = %request.object_id,
461            request_relation = %request.relation,
462            subject_type = %request.subject_type,
463            subject_id = %request.subject_id,
464            exprs = ?exprs,
465            strategy = ?self.strategy,
466            "resolve_union evaluating expressions"
467        );
468        match self.strategy {
469            CheckStrategy::Batch => self.resolve_union_batch(exprs, request).await,
470            CheckStrategy::Parallel => self.resolve_union_parallel(exprs, request).await,
471        }
472    }
473
474    /// Batch strategy: Try to batch DirectAssignment queries for union branches.
475    async fn resolve_union_batch(
476        &self,
477        exprs: &[RelationExpr],
478        request: &ResolveCheckRequest,
479    ) -> Result<CheckResult, AuthzError> {
480        // First, try to batch all DirectAssignment branches
481        let mut direct_assignments = Vec::new();
482        let mut other_exprs = Vec::new();
483
484        for expr in exprs {
485            match expr {
486                RelationExpr::DirectAssignment(_) => direct_assignments.push(expr),
487                _ => other_exprs.push(expr),
488            }
489        }
490
491        tracing::info!(
492            direct_assignments_count = direct_assignments.len(),
493            other_exprs_count = other_exprs.len(),
494            "resolve_union_batch categorizing expressions"
495        );
496
497        if !direct_assignments.is_empty() {
498            // Resolve DirectAssignment branches individually for now
499            tracing::info!("resolve_union_batch evaluating direct assignments individually");
500            for expr in &direct_assignments {
501                if let Ok(CheckResult::Allowed) = self.resolve_relation_expr(expr, request).await {
502                    return Ok(CheckResult::Allowed);
503                }
504            }
505        }
506
507        // Fall back to sequential evaluation for remaining expressions
508        tracing::info!("resolve_union_batch falling back to sequential evaluation");
509        for expr in &other_exprs {
510            if let Ok(CheckResult::Allowed) = self.resolve_relation_expr(expr, request).await {
511                return Ok(CheckResult::Allowed);
512            }
513        }
514
515        Ok(CheckResult::Denied)
516    }
517
518    /// Parallel strategy: Evaluate all union branches concurrently.
519    async fn resolve_union_parallel(
520        &self,
521        exprs: &[RelationExpr],
522        request: &ResolveCheckRequest,
523    ) -> Result<CheckResult, AuthzError> {
524        use futures::FutureExt;
525        use futures::future::select_ok;
526
527        if exprs.is_empty() {
528            return Ok(CheckResult::Denied);
529        }
530
531        // Create futures for all branches
532        let mut futures = Vec::new();
533        for expr in exprs {
534            let future = self
535                .resolve_relation_expr(expr, request)
536                .then(|result| async move {
537                    match result {
538                        Ok(CheckResult::Allowed) => Ok(CheckResult::Allowed),
539                        _ => Err(()),
540                    }
541                });
542            futures.push(Box::pin(future));
543        }
544
545        // Return on first success, or Denied if all fail
546        match select_ok(futures).await {
547            Ok((result, _)) => Ok(result),
548            Err(_) => Ok(CheckResult::Denied),
549        }
550    }
551
552    /// Handle Intersection: [user] and editor
553    async fn resolve_intersection(
554        &self,
555        exprs: &[RelationExpr],
556        request: &ResolveCheckRequest,
557    ) -> Result<CheckResult, AuthzError> {
558        // Sequential evaluation with short-circuit
559        // All must be Allowed for intersection to succeed
560        for expr in exprs {
561            let result = self.resolve_relation_expr(expr, request).await?;
562            match result {
563                CheckResult::Denied => return Ok(CheckResult::Denied),
564                CheckResult::ConditionRequired(params) => {
565                    return Ok(CheckResult::ConditionRequired(params));
566                }
567                CheckResult::Allowed => continue,
568            }
569        }
570        Ok(CheckResult::Allowed)
571    }
572
573    /// Handle Exclusion: [user] but not banned
574    ///
575    /// Exclusion follows boolean logic semantics:
576    /// ALLOWED if base=ALLOWED and subtract=DENIED
577    ///
578    /// This matches our project specification (prd-authz.md).
579    /// Note: Some systems use set difference semantics where A - B means elements in A NOT in B.
580    async fn resolve_exclusion(
581        &self,
582        base: &RelationExpr,
583        subtract: &RelationExpr,
584        request: &ResolveCheckRequest,
585    ) -> Result<CheckResult, AuthzError> {
586        let base_result = self.resolve_relation_expr(base, request).await?;
587        let subtract_result = self.resolve_relation_expr(subtract, request).await?;
588
589        // Allowed only if: base=Allowed AND subtract=Denied
590        // Boolean logic semantics: base=true AND subtract=false = true
591        match (base_result, subtract_result) {
592            (CheckResult::Allowed, CheckResult::Denied) => Ok(CheckResult::Allowed),
593            _ => Ok(CheckResult::Denied),
594        }
595    }
596
597    /// Read tuples with contextual tuples merged in.
598    async fn read_tuples_with_contextual(
599        &self,
600        object_type: &str,
601        object_id: &str,
602        relation: &str,
603        request: &ResolveCheckRequest,
604    ) -> Result<Vec<Tuple>, AuthzError> {
605        let cache_key =
606            Self::tuple_cache_key(&request.at_revision, object_type, object_id, relation);
607
608        // L3 tuple cache is used only when there are no contextual tuples.
609        if request.contextual_tuples.is_empty()
610            && let Some(cached) = self.tuple_cache.get(&cache_key)
611        {
612            tracing::info!(cache_level = "L3", "cache_hit");
613            return Ok(cached);
614        }
615
616        // Read from datastore
617        let filter = TupleFilter {
618            object_type: Some(object_type.to_string()),
619            object_id: Some(object_id.to_string()),
620            relation: Some(relation.to_string()),
621            subject_type: None,
622            subject_id: None,
623        };
624
625        // Bounded datastore reads
626        let _permit = self.read_semaphore.acquire().await.map_err(|e| {
627            AuthzError::Internal(format!("Failed to acquire read semaphore: {}", e))
628        })?;
629
630        // Track datastore query (shared atomic counter)
631        request
632            .metadata
633            .datastore_queries
634            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
635
636        let mut tuples = self.datastore.read_tuples(&filter).await?;
637
638        if request.contextual_tuples.is_empty() {
639            self.tuple_cache.insert(&cache_key, tuples.clone());
640        }
641
642        // Merge contextual tuples
643        for ctx_tuple in &request.contextual_tuples {
644            if ctx_tuple.object_type == object_type
645                && ctx_tuple.object_id == object_id
646                && ctx_tuple.relation == relation
647            {
648                // Avoid duplicates
649                if !tuples.iter().any(|t| {
650                    t.subject_type == ctx_tuple.subject_type && t.subject_id == ctx_tuple.subject_id
651                }) {
652                    tuples.push(ctx_tuple.clone());
653                }
654            }
655        }
656
657        Ok(tuples)
658    }
659}
660
661#[async_trait]
662impl<D, P> CheckResolver for CoreResolver<D, P>
663where
664    D: TupleReader + Clone + Send + Sync + 'static,
665    P: PolicyProvider + Send + Sync + 'static,
666{
667    async fn resolve_check(
668        &self,
669        mut request: ResolveCheckRequest,
670    ) -> Result<CheckResult, AuthzError> {
671        tracing::info!(
672            authz.object_type = %request.object_type,
673            authz.object_id = %request.object_id,
674            authz.relation = %request.relation,
675            authz.depth = request.depth_remaining,
676            authz.dispatch = request.metadata.get_dispatch_count(),
677            "resolve_check",
678        );
679        // Check for cycles if enabled
680        if request.recursion_config.enable_cycle_detection {
681            let current_key = (
682                request.object_type.clone(),
683                request.object_id.clone(),
684                request.relation.clone(),
685            );
686            if request.visited.contains(&current_key) {
687                return Ok(CheckResult::Denied); // Cycle detected
688            }
689            request.visited.push(current_key);
690        }
691
692        // Check depth limit
693        if request.depth_remaining == 0 {
694            return Err(AuthzError::MaxDepthExceeded);
695        }
696
697        // L2 result cache (disabled for contextual tuple checks).
698        let cache_key = Self::result_cache_key(&request);
699        if request.contextual_tuples.is_empty()
700            && let Some(cached) = self.result_cache.get(&cache_key)
701        {
702            tracing::info!(cache_level = "L2", "cache_hit");
703            return Ok(cached);
704        }
705
706        // Increment dispatch count (shared atomic counter)
707        request
708            .metadata
709            .dispatch_count
710            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
711
712        // Track max depth reached: depth = max_depth - depth_remaining
713        let current_depth = request
714            .recursion_config
715            .max_depth
716            .saturating_sub(request.depth_remaining);
717        request
718            .metadata
719            .max_depth_reached
720            .fetch_max(current_depth, std::sync::atomic::Ordering::Relaxed);
721
722        // Get the active policy from the provider (O(1) for StaticPolicyProvider)
723        let type_system = self.policy_provider.get_policy().await?;
724
725        // Get the relation definition from the type system
726        let relation_def = type_system
727            .get_relation(&request.object_type, &request.relation)
728            .ok_or_else(|| {
729                if let Some(type_def) = type_system.get_type(&request.object_type) {
730                    tracing::error!(
731                        object_type = %request.object_type,
732                        relation = %request.relation,
733                        available_relations = ?type_def.relations.iter().map(|r| &r.name).collect::<Vec<_>>(),
734                        available_permissions = ?type_def.permissions.iter().map(|p| &p.name).collect::<Vec<_>>(),
735                        "RelationNotFound error"
736                    );
737                }
738                AuthzError::RelationNotFound {
739                    object_type: request.object_type.clone(),
740                    relation: request.relation.clone(),
741                }
742            })?;
743
744        // Resolve the relation expression
745        let relation_expr = relation_def.expression.clone();
746        let result = self.resolve_relation_expr(&relation_expr, &request).await?;
747
748        if request.contextual_tuples.is_empty() {
749            self.result_cache.insert(&cache_key, result.clone());
750        }
751
752        Ok(result)
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use crate::model_parser::parse_dsl;
760    use crate::policy_provider::StaticPolicyProvider;
761    use crate::traits::Tuple;
762    use crate::type_system::TypeSystem;
763
764    // Mock datastore for testing
765    #[derive(Clone)]
766    struct MockDatastore {
767        tuples: Vec<Tuple>,
768    }
769
770    #[async_trait]
771    impl TupleReader for MockDatastore {
772        async fn read_tuples(&self, filter: &TupleFilter) -> Result<Vec<Tuple>, AuthzError> {
773            Ok(self
774                .tuples
775                .iter()
776                .filter(|t| {
777                    filter
778                        .object_type
779                        .as_ref()
780                        .map_or(true, |v| &t.object_type == v)
781                        && filter
782                            .object_id
783                            .as_ref()
784                            .map_or(true, |v| &t.object_id == v)
785                        && filter.relation.as_ref().map_or(true, |v| &t.relation == v)
786                        && filter
787                            .subject_type
788                            .as_ref()
789                            .map_or(true, |v| &t.subject_type == v)
790                        && filter
791                            .subject_id
792                            .as_ref()
793                            .map_or(true, |v| &t.subject_id == v)
794                })
795                .cloned()
796                .collect())
797        }
798
799        async fn read_user_tuple(
800            &self,
801            _object_type: &str,
802            _object_id: &str,
803            _relation: &str,
804            _subject_type: &str,
805            _subject_id: &str,
806        ) -> Result<Option<Tuple>, AuthzError> {
807            Ok(None)
808        }
809
810        async fn read_userset_tuples(
811            &self,
812            _object_type: &str,
813            _object_id: &str,
814            _relation: &str,
815        ) -> Result<Vec<Tuple>, AuthzError> {
816            Ok(Vec::new())
817        }
818
819        async fn read_starting_with_user(
820            &self,
821            _subject_type: &str,
822            _subject_id: &str,
823        ) -> Result<Vec<Tuple>, AuthzError> {
824            Ok(Vec::new())
825        }
826
827        async fn read_user_tuple_batch(
828            &self,
829            object_type: &str,
830            object_id: &str,
831            relations: &[String],
832            subject_type: &str,
833            subject_id: &str,
834        ) -> Result<Option<Tuple>, AuthzError> {
835            Ok(self
836                .tuples
837                .iter()
838                .find(|t| {
839                    t.object_type == object_type
840                        && t.object_id == object_id
841                        && relations.iter().any(|r| r == &t.relation)
842                        && t.subject_type == subject_type
843                        && t.subject_id == subject_id
844                })
845                .cloned())
846        }
847    }
848
849    #[tokio::test]
850    async fn test_direct_user_match() {
851        let dsl = "type document { relations define viewer: [user] }";
852        let model = parse_dsl(dsl).unwrap();
853        let ts = TypeSystem::new(model);
854
855        let tuples = vec![Tuple {
856            object_type: "document".to_string(),
857            object_id: "doc1".to_string(),
858            relation: "viewer".to_string(),
859            subject_type: "user".to_string(),
860            subject_id: "alice".to_string(),
861            condition: None,
862        }];
863
864        let datastore = MockDatastore { tuples };
865        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
866
867        let request = ResolveCheckRequest::new(
868            "document".into(),
869            "doc1".into(),
870            "viewer".into(),
871            "user".into(),
872            "alice".into(),
873        );
874
875        let result = resolver.resolve_check(request).await.unwrap();
876        assert_eq!(result, CheckResult::Allowed);
877    }
878
879    #[tokio::test]
880    async fn test_direct_user_no_match() {
881        let dsl = "type document { relations define viewer: [user] }";
882        let model = parse_dsl(dsl).unwrap();
883        let ts = TypeSystem::new(model);
884
885        let tuples = vec![Tuple {
886            object_type: "document".to_string(),
887            object_id: "doc1".to_string(),
888            relation: "viewer".to_string(),
889            subject_type: "user".to_string(),
890            subject_id: "alice".to_string(),
891            condition: None,
892        }];
893
894        let datastore = MockDatastore { tuples };
895        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
896
897        let request = ResolveCheckRequest::new(
898            "document".into(),
899            "doc1".into(),
900            "viewer".into(),
901            "user".into(),
902            "bob".into(),
903        );
904
905        let result = resolver.resolve_check(request).await.unwrap();
906        assert_eq!(result, CheckResult::Denied);
907    }
908
909    #[tokio::test]
910    async fn test_computed_userset() {
911        let dsl = "type document { relations define viewer: [user] define can_view: viewer }";
912        let model = parse_dsl(dsl).unwrap();
913        let ts = TypeSystem::new(model);
914
915        let tuples = vec![Tuple {
916            object_type: "document".to_string(),
917            object_id: "doc1".to_string(),
918            relation: "viewer".to_string(),
919            subject_type: "user".to_string(),
920            subject_id: "alice".to_string(),
921            condition: None,
922        }];
923
924        let datastore = MockDatastore { tuples };
925        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
926
927        let request = ResolveCheckRequest::new(
928            "document".into(),
929            "doc1".into(),
930            "can_view".into(),
931            "user".into(),
932            "alice".into(),
933        );
934
935        let result = resolver.resolve_check(request).await.unwrap();
936        assert_eq!(result, CheckResult::Allowed);
937    }
938
939    #[tokio::test]
940    async fn test_cycle_detection() {
941        let dsl = r#"
942            type folder {
943                relations
944                    define parent: [folder]
945                permissions
946                    define view = parent->view
947            }
948            type document {
949                relations
950                    define parent: [folder]
951                permissions
952                    define view = parent->view
953            }
954        "#;
955        let model = parse_dsl(dsl).unwrap();
956        let ts = TypeSystem::new(model);
957
958        // Create a cycle: folder1 -> folder2 -> folder1
959        let tuples = vec![
960            Tuple {
961                object_type: "folder".to_string(),
962                object_id: "folder1".to_string(),
963                relation: "parent".to_string(),
964                subject_type: "folder".to_string(),
965                subject_id: "folder2".to_string(),
966                condition: None,
967            },
968            Tuple {
969                object_type: "folder".to_string(),
970                object_id: "folder2".to_string(),
971                relation: "parent".to_string(),
972                subject_type: "folder".to_string(),
973                subject_id: "folder1".to_string(),
974                condition: None,
975            },
976            Tuple {
977                object_type: "document".to_string(),
978                object_id: "doc1".to_string(),
979                relation: "parent".to_string(),
980                subject_type: "folder".to_string(),
981                subject_id: "folder1".to_string(),
982                condition: None,
983            },
984        ];
985
986        let datastore = MockDatastore { tuples };
987        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
988
989        let request = ResolveCheckRequest::new(
990            "document".into(),
991            "doc1".into(),
992            "view".into(),
993            "user".into(),
994            "alice".into(),
995        );
996
997        let result = resolver.resolve_check(request).await.unwrap();
998        assert_eq!(result, CheckResult::Denied); // Should be denied due to cycle
999    }
1000
1001    #[tokio::test]
1002    async fn test_recursion_config_depth_first() {
1003        let dsl = r#"
1004            type folder {
1005                relations
1006                    define parent: [folder]
1007                permissions
1008                    define view = parent->view
1009            }
1010            type document {
1011                relations
1012                    define parent: [folder]
1013                permissions
1014                    define view = parent->view
1015            }
1016        "#;
1017        let model = parse_dsl(dsl).unwrap();
1018        let ts = TypeSystem::new(model);
1019
1020        // Create a cycle: folder1 -> folder2 -> folder1
1021        let tuples = vec![
1022            Tuple {
1023                object_type: "folder".to_string(),
1024                object_id: "folder1".to_string(),
1025                relation: "parent".to_string(),
1026                subject_type: "folder".to_string(),
1027                subject_id: "folder2".to_string(),
1028                condition: None,
1029            },
1030            Tuple {
1031                object_type: "folder".to_string(),
1032                object_id: "folder2".to_string(),
1033                relation: "parent".to_string(),
1034                subject_type: "folder".to_string(),
1035                subject_id: "folder1".to_string(),
1036                condition: None,
1037            },
1038            Tuple {
1039                object_type: "document".to_string(),
1040                object_id: "doc1".to_string(),
1041                relation: "parent".to_string(),
1042                subject_type: "folder".to_string(),
1043                subject_id: "folder1".to_string(),
1044                condition: None,
1045            },
1046        ];
1047
1048        let datastore = MockDatastore { tuples };
1049        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1050
1051        // Test with depth-first config (default)
1052        let config = crate::resolver::RecursionConfig::depth_first()
1053            .max_depth(10)
1054            .cycle_detection(true);
1055
1056        let request = crate::resolver::ResolveCheckRequest::with_config(
1057            "document".into(),
1058            "doc1".into(),
1059            "view".into(),
1060            "user".into(),
1061            "alice".into(),
1062            config,
1063        );
1064
1065        let result = resolver.resolve_check(request).await.unwrap();
1066        assert_eq!(result, CheckResult::Denied); // Should be denied due to cycle
1067    }
1068
1069    #[tokio::test]
1070    async fn test_recursion_config_breadth_first() {
1071        let dsl = r#"
1072            type folder {
1073                relations
1074                    define owner: [user]
1075                permissions
1076                    define view = owner
1077            }
1078            type document {
1079                relations
1080                    define parent: [folder]
1081                permissions
1082                    define view = parent->view
1083            }
1084        "#;
1085        let model = parse_dsl(dsl).unwrap();
1086        let ts = TypeSystem::new(model);
1087
1088        let tuples = vec![
1089            Tuple {
1090                object_type: "folder".to_string(),
1091                object_id: "folder1".to_string(),
1092                relation: "owner".to_string(),
1093                subject_type: "user".to_string(),
1094                subject_id: "alice".to_string(),
1095                condition: None,
1096            },
1097            Tuple {
1098                object_type: "document".to_string(),
1099                object_id: "doc1".to_string(),
1100                relation: "parent".to_string(),
1101                subject_type: "folder".to_string(),
1102                subject_id: "folder1".to_string(),
1103                condition: None,
1104            },
1105        ];
1106
1107        let datastore = MockDatastore { tuples };
1108        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1109
1110        // Test with breadth-first config
1111        let config = crate::resolver::RecursionConfig::breadth_first()
1112            .max_depth(50)
1113            .cycle_detection(true);
1114
1115        let request = crate::resolver::ResolveCheckRequest::with_config(
1116            "document".into(),
1117            "doc1".into(),
1118            "view".into(),
1119            "user".into(),
1120            "alice".into(),
1121            config,
1122        );
1123
1124        let result = resolver.resolve_check(request).await.unwrap();
1125        assert_eq!(result, CheckResult::Allowed); // Should be allowed
1126    }
1127
1128    #[tokio::test]
1129    async fn test_cycle_detection_disabled() {
1130        let dsl = "type document { relations define viewer: viewer }";
1131        let model = parse_dsl(dsl).unwrap();
1132        let ts = TypeSystem::new(model);
1133
1134        let datastore = MockDatastore { tuples: vec![] };
1135        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1136
1137        // Test with cycle detection disabled
1138        let config = crate::resolver::RecursionConfig::depth_first().cycle_detection(false);
1139
1140        let mut request = crate::resolver::ResolveCheckRequest::with_config(
1141            "document".into(),
1142            "doc1".into(),
1143            "viewer".into(),
1144            "user".into(),
1145            "alice".into(),
1146            config,
1147        );
1148        request.depth_remaining = 1;
1149
1150        let result = resolver.resolve_check(request).await;
1151        assert!(result.is_err()); // Should error due to depth limit
1152        assert!(matches!(result.unwrap_err(), AuthzError::MaxDepthExceeded));
1153    }
1154
1155    #[tokio::test]
1156    async fn test_depth_limit() {
1157        let dsl = "type document { relations define viewer: viewer }";
1158        let model = parse_dsl(dsl).unwrap();
1159        let ts = TypeSystem::new(model);
1160
1161        let datastore = MockDatastore { tuples: vec![] };
1162        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1163
1164        let mut request = ResolveCheckRequest::new(
1165            "document".into(),
1166            "doc1".into(),
1167            "viewer".into(),
1168            "user".into(),
1169            "alice".into(),
1170        );
1171        request.depth_remaining = 1;
1172
1173        let result = resolver.resolve_check(request).await.unwrap();
1174        assert_eq!(result, CheckResult::Denied); // Cycle detected, not depth limit exceeded
1175    }
1176
1177    #[tokio::test]
1178    async fn test_union_first_succeeds() {
1179        let dsl = "type document { relations define viewer: [user] define editor: [user] define can_view: viewer + editor }";
1180        let model = parse_dsl(dsl).unwrap();
1181        let ts = TypeSystem::new(model);
1182
1183        let tuples = vec![Tuple {
1184            object_type: "document".to_string(),
1185            object_id: "doc1".to_string(),
1186            relation: "viewer".to_string(),
1187            subject_type: "user".to_string(),
1188            subject_id: "alice".to_string(),
1189            condition: None,
1190        }];
1191
1192        let datastore = MockDatastore { tuples };
1193        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1194
1195        let request = ResolveCheckRequest::new(
1196            "document".into(),
1197            "doc1".into(),
1198            "can_view".into(),
1199            "user".into(),
1200            "alice".into(),
1201        );
1202
1203        let result = resolver.resolve_check(request).await.unwrap();
1204        assert_eq!(result, CheckResult::Allowed);
1205    }
1206
1207    #[tokio::test]
1208    async fn test_contextual_tuples() {
1209        let dsl = "type document { relations define viewer: [user] }";
1210        let model = parse_dsl(dsl).unwrap();
1211        let ts = TypeSystem::new(model);
1212
1213        let datastore = MockDatastore { tuples: vec![] };
1214        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1215
1216        let mut request = ResolveCheckRequest::new(
1217            "document".into(),
1218            "doc1".into(),
1219            "viewer".into(),
1220            "user".into(),
1221            "alice".into(),
1222        );
1223        request.contextual_tuples = vec![Tuple {
1224            object_type: "document".to_string(),
1225            object_id: "doc1".to_string(),
1226            relation: "viewer".to_string(),
1227            subject_type: "user".to_string(),
1228            subject_id: "alice".to_string(),
1229            condition: None,
1230        }];
1231
1232        let result = resolver.resolve_check(request).await.unwrap();
1233        assert_eq!(result, CheckResult::Allowed);
1234    }
1235
1236    // --- Intersection resolver tests ---
1237
1238    #[tokio::test]
1239    async fn test_intersection_both_allowed() {
1240        let dsl = "type document { relations define viewer: [user] define editor: [user] define can_view: viewer & editor }";
1241        let model = parse_dsl(dsl).unwrap();
1242        let ts = TypeSystem::new(model);
1243
1244        let tuples = vec![
1245            Tuple {
1246                object_type: "document".to_string(),
1247                object_id: "doc1".to_string(),
1248                relation: "viewer".to_string(),
1249                subject_type: "user".to_string(),
1250                subject_id: "alice".to_string(),
1251                condition: None,
1252            },
1253            Tuple {
1254                object_type: "document".to_string(),
1255                object_id: "doc1".to_string(),
1256                relation: "editor".to_string(),
1257                subject_type: "user".to_string(),
1258                subject_id: "alice".to_string(),
1259                condition: None,
1260            },
1261        ];
1262
1263        let datastore = MockDatastore { tuples };
1264        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1265
1266        let request = ResolveCheckRequest::new(
1267            "document".into(),
1268            "doc1".into(),
1269            "can_view".into(),
1270            "user".into(),
1271            "alice".into(),
1272        );
1273
1274        let result = resolver.resolve_check(request).await.unwrap();
1275        assert_eq!(result, CheckResult::Allowed);
1276    }
1277
1278    #[tokio::test]
1279    async fn test_intersection_one_denied() {
1280        let dsl = "type document { relations define viewer: [user] define editor: [user] define can_view: viewer & editor }";
1281        let model = parse_dsl(dsl).unwrap();
1282        let ts = TypeSystem::new(model);
1283
1284        let tuples = vec![Tuple {
1285            object_type: "document".to_string(),
1286            object_id: "doc1".to_string(),
1287            relation: "viewer".to_string(),
1288            subject_type: "user".to_string(),
1289            subject_id: "alice".to_string(),
1290            condition: None,
1291        }];
1292
1293        let datastore = MockDatastore { tuples };
1294        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1295
1296        let request = ResolveCheckRequest::new(
1297            "document".into(),
1298            "doc1".into(),
1299            "can_view".into(),
1300            "user".into(),
1301            "alice".into(),
1302        );
1303
1304        let result = resolver.resolve_check(request).await.unwrap();
1305        assert_eq!(result, CheckResult::Denied);
1306    }
1307
1308    #[tokio::test]
1309    async fn test_intersection_short_circuit_on_denied() {
1310        let dsl = "type document { relations define viewer: [user] define editor: [user] define can_view: viewer & editor }";
1311        let model = parse_dsl(dsl).unwrap();
1312        let ts = TypeSystem::new(model);
1313
1314        let datastore = MockDatastore { tuples: vec![] };
1315        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1316
1317        let request = ResolveCheckRequest::new(
1318            "document".into(),
1319            "doc1".into(),
1320            "can_view".into(),
1321            "user".into(),
1322            "alice".into(),
1323        );
1324
1325        let result = resolver.resolve_check(request).await.unwrap();
1326        assert_eq!(result, CheckResult::Denied);
1327    }
1328
1329    // --- Exclusion (but-not) resolver tests ---
1330
1331    #[tokio::test]
1332    async fn test_exclusion_base_allowed_subtract_denied() {
1333        let dsl = "type document { relations define viewer: [user] define banned: [user] define can_view: viewer - banned }";
1334        let model = parse_dsl(dsl).unwrap();
1335        let ts = TypeSystem::new(model);
1336
1337        let tuples = vec![Tuple {
1338            object_type: "document".to_string(),
1339            object_id: "doc1".to_string(),
1340            relation: "viewer".to_string(),
1341            subject_type: "user".to_string(),
1342            subject_id: "alice".to_string(),
1343            condition: None,
1344        }];
1345
1346        let datastore = MockDatastore { tuples };
1347        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1348
1349        let request = ResolveCheckRequest::new(
1350            "document".into(),
1351            "doc1".into(),
1352            "can_view".into(),
1353            "user".into(),
1354            "alice".into(),
1355        );
1356
1357        let result = resolver.resolve_check(request).await.unwrap();
1358        assert_eq!(result, CheckResult::Allowed);
1359    }
1360
1361    #[tokio::test]
1362    async fn test_exclusion_base_allowed_subtract_allowed() {
1363        let dsl = "type document { relations define viewer: [user] define banned: [user] define can_view: viewer - banned }";
1364        let model = parse_dsl(dsl).unwrap();
1365        let ts = TypeSystem::new(model);
1366
1367        let tuples = vec![
1368            Tuple {
1369                object_type: "document".to_string(),
1370                object_id: "doc1".to_string(),
1371                relation: "viewer".to_string(),
1372                subject_type: "user".to_string(),
1373                subject_id: "alice".to_string(),
1374                condition: None,
1375            },
1376            Tuple {
1377                object_type: "document".to_string(),
1378                object_id: "doc1".to_string(),
1379                relation: "banned".to_string(),
1380                subject_type: "user".to_string(),
1381                subject_id: "alice".to_string(),
1382                condition: None,
1383            },
1384        ];
1385
1386        let datastore = MockDatastore { tuples };
1387        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1388
1389        let request = ResolveCheckRequest::new(
1390            "document".into(),
1391            "doc1".into(),
1392            "can_view".into(),
1393            "user".into(),
1394            "alice".into(),
1395        );
1396
1397        let result = resolver.resolve_check(request).await.unwrap();
1398        assert_eq!(result, CheckResult::Denied);
1399    }
1400
1401    #[tokio::test]
1402    async fn test_exclusion_base_denied() {
1403        let dsl = "type document { relations define viewer: [user] define banned: [user] define can_view: viewer - banned }";
1404        let model = parse_dsl(dsl).unwrap();
1405        let ts = TypeSystem::new(model);
1406
1407        let datastore = MockDatastore { tuples: vec![] };
1408        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1409
1410        let request = ResolveCheckRequest::new(
1411            "document".into(),
1412            "doc1".into(),
1413            "can_view".into(),
1414            "user".into(),
1415            "alice".into(),
1416        );
1417
1418        let result = resolver.resolve_check(request).await.unwrap();
1419        assert_eq!(result, CheckResult::Denied);
1420    }
1421
1422    // --- Tuple-to-userset (TTU) tests ---
1423
1424    #[tokio::test]
1425    async fn test_ttu_single_parent() {
1426        let dsl = "type folder { relations define viewer: [user] } type document { relations define parent: [folder] define viewer: parent->viewer }";
1427        let model = parse_dsl(dsl).unwrap();
1428        let ts = TypeSystem::new(model);
1429
1430        let tuples = vec![
1431            Tuple {
1432                object_type: "document".to_string(),
1433                object_id: "doc1".to_string(),
1434                relation: "parent".to_string(),
1435                subject_type: "folder".to_string(),
1436                subject_id: "root".to_string(),
1437                condition: None,
1438            },
1439            Tuple {
1440                object_type: "folder".to_string(),
1441                object_id: "root".to_string(),
1442                relation: "viewer".to_string(),
1443                subject_type: "user".to_string(),
1444                subject_id: "alice".to_string(),
1445                condition: None,
1446            },
1447        ];
1448
1449        let datastore = MockDatastore { tuples };
1450        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1451
1452        let request = ResolveCheckRequest::new(
1453            "document".into(),
1454            "doc1".into(),
1455            "viewer".into(),
1456            "user".into(),
1457            "alice".into(),
1458        );
1459
1460        let result = resolver.resolve_check(request).await.unwrap();
1461        assert_eq!(result, CheckResult::Allowed);
1462    }
1463
1464    #[tokio::test]
1465    async fn test_ttu_no_parent() {
1466        let dsl = "type folder { relations define viewer: [user] } type document { relations define parent: [folder] define viewer: parent->viewer }";
1467        let model = parse_dsl(dsl).unwrap();
1468        let ts = TypeSystem::new(model);
1469
1470        let datastore = MockDatastore { tuples: vec![] };
1471        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1472
1473        let request = ResolveCheckRequest::new(
1474            "document".into(),
1475            "doc1".into(),
1476            "viewer".into(),
1477            "user".into(),
1478            "alice".into(),
1479        );
1480
1481        let result = resolver.resolve_check(request).await.unwrap();
1482        assert_eq!(result, CheckResult::Denied);
1483    }
1484
1485    #[tokio::test]
1486    async fn test_ttu_multiple_parents() {
1487        let dsl = "type folder { relations define viewer: [user] } type document { relations define parent: [folder] define viewer: parent->viewer }";
1488        let model = parse_dsl(dsl).unwrap();
1489        let ts = TypeSystem::new(model);
1490
1491        let tuples = vec![
1492            Tuple {
1493                object_type: "document".to_string(),
1494                object_id: "doc1".to_string(),
1495                relation: "parent".to_string(),
1496                subject_type: "folder".to_string(),
1497                subject_id: "folder1".to_string(),
1498                condition: None,
1499            },
1500            Tuple {
1501                object_type: "document".to_string(),
1502                object_id: "doc1".to_string(),
1503                relation: "parent".to_string(),
1504                subject_type: "folder".to_string(),
1505                subject_id: "folder2".to_string(),
1506                condition: None,
1507            },
1508            Tuple {
1509                object_type: "folder".to_string(),
1510                object_id: "folder2".to_string(),
1511                relation: "viewer".to_string(),
1512                subject_type: "user".to_string(),
1513                subject_id: "alice".to_string(),
1514                condition: None,
1515            },
1516        ];
1517
1518        let datastore = MockDatastore { tuples };
1519        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1520
1521        let request = ResolveCheckRequest::new(
1522            "document".into(),
1523            "doc1".into(),
1524            "viewer".into(),
1525            "user".into(),
1526            "alice".into(),
1527        );
1528
1529        let result = resolver.resolve_check(request).await.unwrap();
1530        assert_eq!(result, CheckResult::Allowed);
1531    }
1532
1533    // --- Wildcard subject matching tests ---
1534
1535    #[tokio::test]
1536    async fn test_wildcard_match() {
1537        let dsl = "type document { relations define viewer: [user:*] }";
1538        let model = parse_dsl(dsl).unwrap();
1539        let ts = TypeSystem::new(model);
1540
1541        let tuples = vec![Tuple {
1542            object_type: "document".to_string(),
1543            object_id: "doc1".to_string(),
1544            relation: "viewer".to_string(),
1545            subject_type: "user".to_string(),
1546            subject_id: "*".to_string(),
1547            condition: None,
1548        }];
1549
1550        let datastore = MockDatastore { tuples };
1551        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1552
1553        let request = ResolveCheckRequest::new(
1554            "document".into(),
1555            "doc1".into(),
1556            "viewer".into(),
1557            "user".into(),
1558            "alice".into(),
1559        );
1560
1561        let result = resolver.resolve_check(request).await.unwrap();
1562        assert_eq!(result, CheckResult::Allowed);
1563    }
1564
1565    #[tokio::test]
1566    async fn test_wildcard_wrong_type() {
1567        let dsl = "type document { relations define viewer: [user:*] }";
1568        let model = parse_dsl(dsl).unwrap();
1569        let ts = TypeSystem::new(model);
1570
1571        let tuples = vec![Tuple {
1572            object_type: "document".to_string(),
1573            object_id: "doc1".to_string(),
1574            relation: "viewer".to_string(),
1575            subject_type: "user".to_string(),
1576            subject_id: "*".to_string(),
1577            condition: None,
1578        }];
1579
1580        let datastore = MockDatastore { tuples };
1581        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1582
1583        let request = ResolveCheckRequest::new(
1584            "document".into(),
1585            "doc1".into(),
1586            "viewer".into(),
1587            "employee".into(),
1588            "alice".into(),
1589        );
1590
1591        let result = resolver.resolve_check(request).await.unwrap();
1592        assert_eq!(result, CheckResult::Denied);
1593    }
1594
1595    // --- Userset expansion tests ---
1596
1597    #[tokio::test]
1598    async fn test_userset_expansion() {
1599        let dsl = "type group { relations define member: [user] } type document { relations define viewer: [group#member] }";
1600        let model = parse_dsl(dsl).unwrap();
1601        let ts = TypeSystem::new(model);
1602
1603        let tuples = vec![
1604            Tuple {
1605                object_type: "document".to_string(),
1606                object_id: "doc1".to_string(),
1607                relation: "viewer".to_string(),
1608                subject_type: "group".to_string(),
1609                subject_id: "eng".to_string(),
1610                condition: None,
1611            },
1612            Tuple {
1613                object_type: "group".to_string(),
1614                object_id: "eng".to_string(),
1615                relation: "member".to_string(),
1616                subject_type: "user".to_string(),
1617                subject_id: "alice".to_string(),
1618                condition: None,
1619            },
1620        ];
1621
1622        let datastore = MockDatastore { tuples };
1623        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1624
1625        let request = ResolveCheckRequest::new(
1626            "document".into(),
1627            "doc1".into(),
1628            "viewer".into(),
1629            "user".into(),
1630            "alice".into(),
1631        );
1632
1633        let result = resolver.resolve_check(request).await.unwrap();
1634        assert_eq!(result, CheckResult::Allowed);
1635    }
1636
1637    #[tokio::test]
1638    async fn test_userset_expansion_no_member() {
1639        let dsl = "type group { relations define member: [user] } type document { relations define viewer: [group#member] }";
1640        let model = parse_dsl(dsl).unwrap();
1641        let ts = TypeSystem::new(model);
1642
1643        let tuples = vec![Tuple {
1644            object_type: "document".to_string(),
1645            object_id: "doc1".to_string(),
1646            relation: "viewer".to_string(),
1647            subject_type: "group".to_string(),
1648            subject_id: "eng".to_string(),
1649            condition: None,
1650        }];
1651
1652        let datastore = MockDatastore { tuples };
1653        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1654
1655        let request = ResolveCheckRequest::new(
1656            "document".into(),
1657            "doc1".into(),
1658            "viewer".into(),
1659            "user".into(),
1660            "alice".into(),
1661        );
1662
1663        let result = resolver.resolve_check(request).await.unwrap();
1664        assert_eq!(result, CheckResult::Denied);
1665    }
1666
1667    // --- Conditional test ---
1668
1669    #[tokio::test]
1670    async fn test_conditional_returns_condition_required() {
1671        let dsl = "type document { relations define viewer: [user with ip_check] } condition ip_check(ip: string) { ip == \"127.0.0.1\" }";
1672        let model = parse_dsl(dsl).unwrap();
1673        let ts = TypeSystem::new(model);
1674
1675        let tuples = vec![Tuple {
1676            object_type: "document".to_string(),
1677            object_id: "doc1".to_string(),
1678            relation: "viewer".to_string(),
1679            subject_type: "user".to_string(),
1680            subject_id: "alice".to_string(),
1681            condition: Some("ip_check".to_string()),
1682        }];
1683
1684        let datastore = MockDatastore { tuples };
1685        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1686
1687        let request = ResolveCheckRequest::new(
1688            "document".into(),
1689            "doc1".into(),
1690            "viewer".into(),
1691            "user".into(),
1692            "alice".into(),
1693        );
1694
1695        let result = resolver.resolve_check(request).await.unwrap();
1696        assert_eq!(
1697            result,
1698            CheckResult::ConditionRequired(vec!["ip_check".to_string()])
1699        );
1700    }
1701
1702    // --- L2 result cache tests ---
1703
1704    #[tokio::test]
1705    async fn test_result_cache_hit() {
1706        let dsl = "type document { relations define viewer: [user] }";
1707        let model = parse_dsl(dsl).unwrap();
1708        let ts = TypeSystem::new(model);
1709
1710        let tuples = vec![Tuple {
1711            object_type: "document".to_string(),
1712            object_id: "doc1".to_string(),
1713            relation: "viewer".to_string(),
1714            subject_type: "user".to_string(),
1715            subject_id: "alice".to_string(),
1716            condition: None,
1717        }];
1718
1719        let datastore = MockDatastore { tuples };
1720        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1721
1722        let request = ResolveCheckRequest::new(
1723            "document".into(),
1724            "doc1".into(),
1725            "viewer".into(),
1726            "user".into(),
1727            "alice".into(),
1728        );
1729
1730        let result1 = resolver.resolve_check(request.clone()).await.unwrap();
1731        let result2 = resolver.resolve_check(request).await.unwrap();
1732
1733        assert_eq!(result1, CheckResult::Allowed);
1734        assert_eq!(result2, CheckResult::Allowed);
1735    }
1736
1737    #[tokio::test]
1738    async fn test_result_cache_bypass_with_contextual_tuples() {
1739        let dsl = "type document { relations define viewer: [user] }";
1740        let model = parse_dsl(dsl).unwrap();
1741        let ts = TypeSystem::new(model);
1742
1743        let datastore = MockDatastore { tuples: vec![] };
1744        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1745
1746        let mut request = ResolveCheckRequest::new(
1747            "document".into(),
1748            "doc1".into(),
1749            "viewer".into(),
1750            "user".into(),
1751            "alice".into(),
1752        );
1753        request.contextual_tuples = vec![Tuple {
1754            object_type: "document".to_string(),
1755            object_id: "doc1".to_string(),
1756            relation: "viewer".to_string(),
1757            subject_type: "user".to_string(),
1758            subject_id: "alice".to_string(),
1759            condition: None,
1760        }];
1761
1762        let result = resolver.resolve_check(request).await.unwrap();
1763        assert_eq!(result, CheckResult::Allowed);
1764    }
1765
1766    #[tokio::test]
1767    async fn test_result_cache_key_differs_for_different_subjects() {
1768        let dsl = "type document { relations define viewer: [user] }";
1769        let model = parse_dsl(dsl).unwrap();
1770        let ts = TypeSystem::new(model);
1771
1772        let tuples = vec![Tuple {
1773            object_type: "document".to_string(),
1774            object_id: "doc1".to_string(),
1775            relation: "viewer".to_string(),
1776            subject_type: "user".to_string(),
1777            subject_id: "alice".to_string(),
1778            condition: None,
1779        }];
1780
1781        let datastore = MockDatastore { tuples };
1782        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1783
1784        let request_alice = ResolveCheckRequest::new(
1785            "document".into(),
1786            "doc1".into(),
1787            "viewer".into(),
1788            "user".into(),
1789            "alice".into(),
1790        );
1791        let request_bob = ResolveCheckRequest::new(
1792            "document".into(),
1793            "doc1".into(),
1794            "viewer".into(),
1795            "user".into(),
1796            "bob".into(),
1797        );
1798
1799        let result_alice = resolver.resolve_check(request_alice).await.unwrap();
1800        let result_bob = resolver.resolve_check(request_bob).await.unwrap();
1801
1802        assert_eq!(result_alice, CheckResult::Allowed);
1803        assert_eq!(result_bob, CheckResult::Denied);
1804    }
1805
1806    // --- L3 tuple cache tests ---
1807
1808    #[tokio::test]
1809    async fn test_tuple_cache_hit() {
1810        let dsl = "type document { relations define viewer: [user] }";
1811        let model = parse_dsl(dsl).unwrap();
1812        let ts = TypeSystem::new(model);
1813
1814        let tuples = vec![Tuple {
1815            object_type: "document".to_string(),
1816            object_id: "doc1".to_string(),
1817            relation: "viewer".to_string(),
1818            subject_type: "user".to_string(),
1819            subject_id: "alice".to_string(),
1820            condition: None,
1821        }];
1822
1823        let datastore = MockDatastore { tuples };
1824        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1825
1826        let request = ResolveCheckRequest::new(
1827            "document".into(),
1828            "doc1".into(),
1829            "viewer".into(),
1830            "user".into(),
1831            "alice".into(),
1832        );
1833
1834        let _result1 = resolver.resolve_check(request.clone()).await.unwrap();
1835        let result2 = resolver.resolve_check(request).await.unwrap();
1836
1837        assert_eq!(result2, CheckResult::Allowed);
1838    }
1839
1840    #[tokio::test]
1841    async fn test_tuple_cache_bypass_with_contextual() {
1842        let dsl = "type document { relations define viewer: [user] }";
1843        let model = parse_dsl(dsl).unwrap();
1844        let ts = TypeSystem::new(model);
1845
1846        let datastore = MockDatastore { tuples: vec![] };
1847        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1848
1849        let mut request = ResolveCheckRequest::new(
1850            "document".into(),
1851            "doc1".into(),
1852            "viewer".into(),
1853            "user".into(),
1854            "alice".into(),
1855        );
1856        request.contextual_tuples = vec![Tuple {
1857            object_type: "document".to_string(),
1858            object_id: "doc1".to_string(),
1859            relation: "viewer".to_string(),
1860            subject_type: "user".to_string(),
1861            subject_id: "alice".to_string(),
1862            condition: None,
1863        }];
1864
1865        let result = resolver.resolve_check(request).await.unwrap();
1866        assert_eq!(result, CheckResult::Allowed);
1867    }
1868
1869    // --- Error path test ---
1870
1871    #[tokio::test]
1872    async fn test_unknown_relation_returns_error() {
1873        let dsl = "type document { relations define viewer: [user] }";
1874        let model = parse_dsl(dsl).unwrap();
1875        let ts = TypeSystem::new(model);
1876
1877        let datastore = MockDatastore { tuples: vec![] };
1878        let resolver = CoreResolver::new(datastore, StaticPolicyProvider::new(ts));
1879
1880        let request = ResolveCheckRequest::new(
1881            "document".into(),
1882            "doc1".into(),
1883            "unknown_relation".into(),
1884            "user".into(),
1885            "alice".into(),
1886        );
1887
1888        let result = resolver.resolve_check(request).await;
1889        assert!(result.is_err());
1890        assert!(matches!(
1891            result.unwrap_err(),
1892            AuthzError::RelationNotFound { .. }
1893        ));
1894    }
1895}