/// Access Evaluator: Orchestrate all access checks /// /// Flow: /// 1. Resolve roles from claims /// 2. For each role, find matching rules /// 3. For each rule, check verb + scope /// 4. First match wins (allow), else deny use std::sync::Arc; use super::role_provider::RoleProvider; use super::scope_checker::{CompositeScopeChecker, ScopeResult}; use super::types::{AccessDecision, Claims, DenyReason, ResourceMeta, Role, Verb}; pub use super::types::HasResourceMeta; // ============================================================================ // Access Evaluator // ============================================================================ /// Evaluates access requests against roles and scopes pub struct AccessEvaluator { role_provider: Arc, scope_checker: CompositeScopeChecker, } impl AccessEvaluator { pub fn new(role_provider: Arc) -> Self { Self { role_provider, scope_checker: CompositeScopeChecker::new(), } } /// Custom scope checker pub fn with_scope_checker(mut self, checker: CompositeScopeChecker) -> Self { self.scope_checker = checker; self } /// Evaluate access for a single resource pub async fn evaluate( &self, claims: &Claims, resource: &ResourceMeta, verb: Verb, ) -> AccessDecision { // Get all roles for this user let roles = match self.role_provider.get_roles(&claims.roles).await { Ok(roles) => roles, Err(_) => return AccessDecision::Deny { reason: DenyReason::NoMatchingRole }, }; if roles.is_empty() { return AccessDecision::Deny { reason: DenyReason::NoMatchingRole }; } // Check each role for role in &roles { if let Some(decision) = self.evaluate_role(claims, resource, verb, role) { if decision.is_allowed() { return decision; } } } AccessDecision::Deny { reason: DenyReason::NoMatchingRule } } /// Evaluate a single role fn evaluate_role( &self, claims: &Claims, resource: &ResourceMeta, verb: Verb, role: &Role, ) -> Option { // Find rules that match the resource type let matching_rules = role.rules_for_resource(resource.resource_type); for (rule_idx, rule) in matching_rules.iter().enumerate() { // Check verb if !rule.allows_verb(verb) { continue; } // Check scope match self.scope_checker.check_all(claims, resource, &rule.scope) { ScopeResult::Pass | ScopeResult::NotApplicable => { return Some(AccessDecision::Allow { matched_role: role.name.clone(), matched_rule_index: rule_idx, }); } ScopeResult::Fail(_) => continue, } } None } /// Evaluate capability-level access (without specific resource) /// Used for HTTP endpoint checks like "can user read anything?" pub async fn evaluate_capability(&self, claims: &Claims, verb: Verb) -> bool { let roles = match self.role_provider.get_roles(&claims.roles).await { Ok(roles) => roles, Err(_) => return false, }; for role in &roles { for rule in &role.rules { if rule.allows_verb(verb) { return true; } } } false } /// Filter resources by access pub async fn filter( &self, claims: &Claims, verb: Verb, resources: Vec, ) -> FilterResult { let mut allowed = Vec::new(); let mut denied = Vec::new(); for resource in resources { let meta = resource.resource_meta(); match self.evaluate(claims, meta, verb).await { AccessDecision::Allow { .. } => allowed.push(resource), AccessDecision::Deny { reason } => { denied.push((meta.id.clone(), reason)); } } } FilterResult { allowed, denied } } /// Batch evaluation for multiple resources (optimized) pub async fn evaluate_batch( &self, claims: &Claims, resources: &[ResourceMeta], verb: Verb, ) -> Vec { // Pre-fetch roles once let roles = match self.role_provider.get_roles(&claims.roles).await { Ok(roles) => roles, Err(_) => { return resources .iter() .map(|_| AccessDecision::Deny { reason: DenyReason::NoMatchingRole }) .collect(); } }; if roles.is_empty() { return resources .iter() .map(|_| AccessDecision::Deny { reason: DenyReason::NoMatchingRole }) .collect(); } resources .iter() .map(|resource| { for role in &roles { if let Some(decision) = self.evaluate_role(claims, resource, verb, role) { if decision.is_allowed() { return decision; } } } AccessDecision::Deny { reason: DenyReason::NoMatchingRule } }) .collect() } } /// Result of filtering resources by access #[derive(Debug)] pub struct FilterResult { pub allowed: Vec, pub denied: Vec<(String, DenyReason)>, } impl FilterResult { pub fn all_allowed(&self) -> bool { self.denied.is_empty() } pub fn none_allowed(&self) -> bool { self.allowed.is_empty() } pub fn stats(&self) -> (usize, usize) { (self.allowed.len(), self.denied.len()) } } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; use crate::rbac::role_provider::{admin_role, authenticated_user_role, portfolio_agent_role, InMemoryRoleProvider}; use crate::rbac::types::{AccessRule, AccessScope, ResourceType, Visibility}; fn test_provider() -> Arc { Arc::new( InMemoryRoleProvider::new() .add_role(admin_role()) .add_role(portfolio_agent_role()) .add_role(authenticated_user_role()) ) } // ======================================================================== // Admin Tests // ======================================================================== #[tokio::test] async fn test_admin_full_access() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("root").with_roles(vec!["admin"]); let wiki = ResourceMeta::wiki("doc-1", "secret-project") .with_visibility(Visibility::Private); let result = evaluator.evaluate(&claims, &wiki, Verb::Write).await; assert!(result.is_allowed()); } #[tokio::test] async fn test_admin_delete_conversation() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("root").with_roles(vec!["admin"]); let conv = ResourceMeta::conversation("conv-1", "portfolio", "other-user"); let result = evaluator.evaluate(&claims, &conv, Verb::Delete).await; assert!(result.is_allowed()); } // ======================================================================== // Portfolio Agent Tests // ======================================================================== #[tokio::test] async fn test_portfolio_agent_read_public_wiki() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); let wiki = ResourceMeta::wiki("doc-1", "homelab") .with_visibility(Visibility::Public); let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await; assert!(result.is_allowed()); } #[tokio::test] async fn test_portfolio_agent_denied_private_wiki() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); let wiki = ResourceMeta::wiki("secret", "homelab") .with_visibility(Visibility::Private); let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await; assert!(result.is_denied()); } #[tokio::test] async fn test_portfolio_agent_denied_wrong_project() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); let wiki = ResourceMeta::wiki("doc-1", "secret-project") .with_visibility(Visibility::Public); let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await; assert!(result.is_denied()); } #[tokio::test] async fn test_portfolio_agent_own_conversation() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]); let conv = ResourceMeta::conversation("conv-1", "portfolio", "visitor-123"); let result = evaluator.evaluate(&claims, &conv, Verb::Write).await; assert!(result.is_allowed()); } #[tokio::test] async fn test_portfolio_agent_denied_other_conversation() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]); let conv = ResourceMeta::conversation("conv-1", "portfolio", "other-user"); let result = evaluator.evaluate(&claims, &conv, Verb::Read).await; assert!(result.is_denied()); } #[tokio::test] async fn test_portfolio_agent_denied_write_wiki() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); let wiki = ResourceMeta::wiki("doc-1", "homelab") .with_visibility(Visibility::Public); let result = evaluator.evaluate(&claims, &wiki, Verb::Write).await; assert!(result.is_denied()); } // ======================================================================== // Authenticated User Tests // ======================================================================== #[tokio::test] async fn test_auth_user_read_any_wiki() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("alice").with_roles(vec!["authenticated-user"]); // Can read private wiki in any project let wiki = ResourceMeta::wiki("secret", "secret-project") .with_visibility(Visibility::Private); let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await; assert!(result.is_allowed()); } #[tokio::test] async fn test_auth_user_own_conversation() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("alice").with_roles(vec!["authenticated-user"]); let conv = ResourceMeta::conversation("conv-1", "any-project", "alice"); // Can read, write, delete own conversations assert!(evaluator.evaluate(&claims, &conv, Verb::Read).await.is_allowed()); assert!(evaluator.evaluate(&claims, &conv, Verb::Write).await.is_allowed()); assert!(evaluator.evaluate(&claims, &conv, Verb::Delete).await.is_allowed()); } // ======================================================================== // No Role Tests // ======================================================================== #[tokio::test] async fn test_no_role_denied() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("anonymous"); // No roles let wiki = ResourceMeta::wiki("public", "homelab") .with_visibility(Visibility::Public); let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await; assert!(result.is_denied()); assert!(matches!(result, AccessDecision::Deny { reason: DenyReason::NoMatchingRole })); } #[tokio::test] async fn test_unknown_role_denied() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("user").with_roles(vec!["nonexistent-role"]); let wiki = ResourceMeta::wiki("public", "homelab"); let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await; assert!(result.is_denied()); } // ======================================================================== // Capability Tests // ======================================================================== #[tokio::test] async fn test_capability_admin_can_write() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("root").with_roles(vec!["admin"]); assert!(evaluator.evaluate_capability(&claims, Verb::Write).await); assert!(evaluator.evaluate_capability(&claims, Verb::Delete).await); } #[tokio::test] async fn test_capability_portfolio_agent_read_only() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); assert!(evaluator.evaluate_capability(&claims, Verb::Read).await); assert!(evaluator.evaluate_capability(&claims, Verb::Query).await); assert!(evaluator.evaluate_capability(&claims, Verb::Write).await); // Has write for conversations assert!(!evaluator.evaluate_capability(&claims, Verb::Delete).await); } // ======================================================================== // Filter Tests // ======================================================================== #[tokio::test] async fn test_filter_resources() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); let resources = vec![ ResourceMeta::wiki("public-1", "homelab").with_visibility(Visibility::Public), ResourceMeta::wiki("private-1", "homelab").with_visibility(Visibility::Private), ResourceMeta::wiki("public-2", "portfolio").with_visibility(Visibility::Public), ResourceMeta::wiki("wrong-project", "secret").with_visibility(Visibility::Public), ]; let result = evaluator.filter(&claims, Verb::Read, resources).await; assert_eq!(result.allowed.len(), 2); // public-1, public-2 assert_eq!(result.denied.len(), 2); // private-1, wrong-project } // ======================================================================== // Batch Evaluation Tests // ======================================================================== #[tokio::test] async fn test_batch_evaluation() { let evaluator = AccessEvaluator::new(test_provider()); let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]); let resources = vec![ ResourceMeta::wiki("public", "homelab").with_visibility(Visibility::Public), ResourceMeta::wiki("private", "homelab").with_visibility(Visibility::Private), ]; let decisions = evaluator.evaluate_batch(&claims, &resources, Verb::Read).await; assert_eq!(decisions.len(), 2); assert!(decisions[0].is_allowed()); assert!(decisions[1].is_denied()); } // ======================================================================== // Multiple Roles Tests // ======================================================================== #[tokio::test] async fn test_multiple_roles_combined() { let evaluator = AccessEvaluator::new(test_provider()); // User with both portfolio-agent and authenticated-user roles let claims = Claims::new("power-user") .with_roles(vec!["portfolio-agent", "authenticated-user"]); // Private wiki - denied by portfolio-agent, allowed by authenticated-user let private_wiki = ResourceMeta::wiki("secret", "homelab") .with_visibility(Visibility::Private); let result = evaluator.evaluate(&claims, &private_wiki, Verb::Read).await; assert!(result.is_allowed()); // authenticated-user allows it } }