//! Policy evaluation engine //! //! The core authorization engine that evaluates whether a principal //! is allowed to perform an action on a resource. use std::sync::Arc; use iam_store::{BindingStore, GroupStore, RoleStore}; use iam_types::{ PolicyBinding, Principal, PrincipalKind, PrincipalRef, Resource, Result, Role, Scope, }; use crate::cache::PolicyCache; use crate::condition::evaluate_condition; use crate::context::{AuthzContext, VariableContext}; /// Authorization request #[derive(Debug, Clone)] pub struct AuthzRequest { /// Principal making the request pub principal: Principal, /// Action being performed (e.g., "compute:instances:create") pub action: String, /// Resource being accessed pub resource: Resource, /// Request context pub context: AuthzContext, } impl AuthzRequest { /// Create a new authorization request pub fn new(principal: Principal, action: impl Into, resource: Resource) -> Self { Self { principal, action: action.into(), resource, context: AuthzContext::new(), } } /// Set the context pub fn with_context(mut self, context: AuthzContext) -> Self { self.context = context; self } } /// Authorization decision #[derive(Debug, Clone)] pub enum AuthzDecision { /// Access is allowed Allow, /// Access is denied with reason Deny { reason: String }, } /// Authorization decision with match metadata #[derive(Debug, Clone)] pub struct AuthzEvaluation { /// Decision result pub decision: AuthzDecision, /// Matched binding ID (if allowed) pub matched_binding: Option, /// Matched role reference (if allowed) pub matched_role: Option, } impl AuthzEvaluation { fn allow(binding_id: impl Into, role_ref: impl Into) -> Self { Self { decision: AuthzDecision::Allow, matched_binding: Some(binding_id.into()), matched_role: Some(role_ref.into()), } } fn deny(reason: impl Into) -> Self { Self { decision: AuthzDecision::Deny { reason: reason.into(), }, matched_binding: None, matched_role: None, } } } impl AuthzDecision { /// Check if the decision is Allow pub fn is_allowed(&self) -> bool { matches!(self, AuthzDecision::Allow) } /// Check if the decision is Deny pub fn is_denied(&self) -> bool { matches!(self, AuthzDecision::Deny { .. }) } } /// Policy evaluator configuration #[derive(Debug, Clone)] pub struct PolicyEvaluatorConfig { /// Whether to use caching pub use_cache: bool, /// Maximum number of bindings to evaluate pub max_bindings: usize, /// Enable debug logging pub debug: bool, } impl Default for PolicyEvaluatorConfig { fn default() -> Self { Self { use_cache: true, max_bindings: 1000, debug: false, } } } /// Policy evaluator (PDP - Policy Decision Point) pub struct PolicyEvaluator { binding_store: Arc, role_store: Arc, group_store: Option>, cache: Arc, config: PolicyEvaluatorConfig, } impl PolicyEvaluator { /// Create a new policy evaluator pub fn new( binding_store: Arc, role_store: Arc, cache: Arc, ) -> Self { Self { binding_store, role_store, group_store: None, cache, config: PolicyEvaluatorConfig::default(), } } /// Create with group store for group expansion pub fn with_group_store( binding_store: Arc, role_store: Arc, group_store: Arc, cache: Arc, ) -> Self { Self { binding_store, role_store, group_store: Some(group_store), cache, config: PolicyEvaluatorConfig::default(), } } /// Set configuration pub fn with_config(mut self, config: PolicyEvaluatorConfig) -> Self { self.config = config; self } /// Evaluate an authorization request pub async fn evaluate(&self, req: &AuthzRequest) -> Result { Ok(self.evaluate_with_match(req).await?.decision) } /// Evaluate an authorization request with match metadata pub async fn evaluate_with_match(&self, req: &AuthzRequest) -> Result { // Default deny let mut evaluation = AuthzEvaluation::deny("No matching policy"); // Get resource scope let resource_scope = Scope::resource( &req.resource.id, &req.resource.project_id, &req.resource.org_id, ); // Get effective bindings for the principal let bindings = self .get_effective_bindings(&req.principal.to_ref(), &resource_scope) .await?; if bindings.is_empty() { return Ok(AuthzEvaluation::deny(format!( "No bindings found for principal {}", req.principal.to_ref() ))); } // Evaluate each binding for binding in bindings.iter().take(self.config.max_bindings) { // Skip disabled or expired bindings let now = req.context.timestamp; if !binding.is_active(now) { continue; } // Get the role let role = match self.get_role(&binding.role_ref).await? { Some(r) => r, None => { tracing::warn!("Role not found: {}", binding.role_ref); continue; } }; // Evaluate binding condition (if any) if let Some(cond) = &binding.condition { let var_ctx = VariableContext::new(&req.principal, &req.resource, &req.context); if !evaluate_condition(cond, &var_ctx)? { continue; } } // Evaluate role permissions if self.evaluate_role(&role, req)? { evaluation = AuthzEvaluation::allow(binding.id.clone(), binding.role_ref.clone()); break; } } Ok(evaluation) } /// Check if a specific action is allowed pub async fn is_allowed( &self, principal: &Principal, action: &str, resource: &Resource, ) -> Result { let req = AuthzRequest::new(principal.clone(), action, resource.clone()); let decision = self.evaluate(&req).await?; Ok(decision.is_allowed()) } /// Check if a specific action is allowed with context pub async fn is_allowed_with_context( &self, principal: &Principal, action: &str, resource: &Resource, context: AuthzContext, ) -> Result { let req = AuthzRequest::new(principal.clone(), action, resource.clone()).with_context(context); let decision = self.evaluate(&req).await?; Ok(decision.is_allowed()) } /// Evaluate role permissions against the request fn evaluate_role(&self, role: &Role, req: &AuthzRequest) -> Result { let var_ctx = VariableContext::new(&req.principal, &req.resource, &req.context); for permission in &role.permissions { // Check action pattern if !matches_action(&permission.action, &req.action) { continue; } // Check resource pattern let resource_path = req.resource.to_path(); let pattern = var_ctx.substitute(&permission.resource_pattern); if !matches_resource(&pattern, &resource_path) { continue; } // Check permission condition (if any) if let Some(cond) = &permission.condition { if !evaluate_condition(cond, &var_ctx)? { continue; } } // All checks passed - permission matches return Ok(true); } Ok(false) } /// Get effective bindings for a principal (with caching) /// /// If group_store is configured, this will also include bindings /// for any groups the principal belongs to. async fn get_effective_bindings( &self, principal: &PrincipalRef, scope: &Scope, ) -> Result> { // Check cache first if self.config.use_cache { if let Some(bindings) = self.cache.get_bindings(principal) { // Filter to scope let effective: Vec<_> = bindings .into_iter() .filter(|b| b.scope.contains(scope)) .collect(); return Ok(effective); } } // Fetch bindings for the principal let mut bindings = self .binding_store .get_effective_bindings(principal, scope) .await?; // If group store is available, expand group memberships if let Some(group_store) = &self.group_store { // Get groups the principal belongs to let groups = group_store.list_groups(principal).await?; // Get bindings for each group for group_id in groups { let group_ref = PrincipalRef::new(PrincipalKind::Group, &group_id); let group_bindings = self .binding_store .get_effective_bindings(&group_ref, scope) .await?; bindings.extend(group_bindings); } } // Cache if enabled if self.config.use_cache { self.cache.put_bindings(principal, bindings.clone()); } Ok(bindings) } /// Get a role (with caching) async fn get_role(&self, role_ref: &str) -> Result> { let name = role_ref.strip_prefix("roles/").unwrap_or(role_ref); // Check cache first if self.config.use_cache { if let Some(role) = self.cache.get_role(name) { return Ok(Some(role)); } } // Fetch from store let role = self.role_store.get(name).await?; // Cache if enabled and found if self.config.use_cache { if let Some(ref r) = role { self.cache.put_role(r.clone()); } } Ok(role) } /// Invalidate cache for a principal pub fn invalidate_principal(&self, principal: &PrincipalRef) { self.cache.invalidate_bindings(principal); } /// Invalidate cache for a role pub fn invalidate_role(&self, role_name: &str) { self.cache.invalidate_role(role_name); } /// Invalidate all caches pub fn invalidate_all(&self) { self.cache.invalidate_all(); } } /// Match action pattern against action /// Supports wildcards: "*" matches everything, "compute:*" matches all compute actions fn matches_action(pattern: &str, action: &str) -> bool { if pattern == "*" { return true; } if !pattern.contains('*') { return pattern == action; } // Split by ":" let pattern_parts: Vec<&str> = pattern.split(':').collect(); let action_parts: Vec<&str> = action.split(':').collect(); // Compare each part for (i, p) in pattern_parts.iter().enumerate() { if *p == "*" { // Wildcard matches rest if i == pattern_parts.len() - 1 { return true; } continue; } if i >= action_parts.len() { return false; } if *p != action_parts[i] { return false; } } pattern_parts.len() == action_parts.len() } /// Match resource pattern against resource path fn matches_resource(pattern: &str, path: &str) -> bool { if pattern == "*" { return true; } // Handle trailing /* as "match all remaining" ONLY if there are no other wildcards // This allows patterns like "project/p1/*" to match "project/p1/instance/vm-1" if let Some(prefix) = pattern.strip_suffix("/*") { // Only use special handling if prefix has no wildcards if !prefix.contains('*') && !prefix.contains('?') { return path.starts_with(prefix) && path.len() > prefix.len() && path.as_bytes()[prefix.len()] == b'/'; } } // Use glob matching for other patterns glob_match::glob_match(pattern, path) } #[cfg(test)] mod tests { use super::*; use iam_store::Backend; use iam_types::Permission; fn test_stores() -> (Arc, Arc) { let backend = Arc::new(Backend::memory()); let binding_store = Arc::new(BindingStore::new(backend.clone())); let role_store = Arc::new(RoleStore::new(backend)); (binding_store, role_store) } #[test] fn test_action_matching() { assert!(matches_action("*", "compute:instances:create")); assert!(matches_action("compute:*", "compute:instances:create")); assert!(matches_action( "compute:instances:*", "compute:instances:create" )); assert!(matches_action( "compute:instances:create", "compute:instances:create" )); assert!(!matches_action( "compute:instances:delete", "compute:instances:create" )); assert!(!matches_action("storage:*", "compute:instances:create")); } #[test] fn test_resource_matching() { assert!(matches_resource("*", "project/p1/instance/vm-1")); assert!(matches_resource( "project/*/instance/*", "project/p1/instance/vm-1" )); assert!(matches_resource( "project/p1/*", "project/p1/instance/vm-1" )); assert!(!matches_resource( "project/p2/*", "project/p1/instance/vm-1" )); } #[tokio::test] async fn test_evaluator_allow() { let (binding_store, role_store) = test_stores(); let cache = Arc::new(PolicyCache::default_config()); // Initialize builtin roles role_store.init_builtin_roles().await.unwrap(); // Create a binding for alice as SystemAdmin let alice = PrincipalRef::user("alice"); binding_store .create(&PolicyBinding::new( "b1", alice.clone(), "roles/SystemAdmin", Scope::System, )) .await .unwrap(); let evaluator = PolicyEvaluator::new(binding_store, role_store, cache); let principal = Principal::new_user("alice", "Alice"); let resource = Resource::new("instance", "vm-1", "org-1", "proj-1"); let req = AuthzRequest::new(principal, "compute:instances:delete", resource); let decision = evaluator.evaluate(&req).await.unwrap(); assert!(decision.is_allowed()); } #[tokio::test] async fn test_evaluator_resource_scoped_binding() { let (binding_store, role_store) = test_stores(); let cache = Arc::new(PolicyCache::default_config()); // Initialize builtin roles role_store.init_builtin_roles().await.unwrap(); // Bind alice to a single resource let alice = PrincipalRef::user("alice"); binding_store .create(&PolicyBinding::new( "b1", alice.clone(), "roles/SystemAdmin", Scope::resource("vm-1", "proj-1", "org-1"), )) .await .unwrap(); let evaluator = PolicyEvaluator::new(binding_store, role_store, cache); let principal = Principal::new_user("alice", "Alice"); // Matching resource should be allowed let resource = Resource::new("instance", "vm-1", "org-1", "proj-1"); let decision = evaluator .evaluate(&AuthzRequest::new( principal.clone(), "compute:instances:delete", resource, )) .await .unwrap(); assert!(decision.is_allowed()); // Different resource should be denied let resource = Resource::new("instance", "vm-2", "org-1", "proj-1"); let decision = evaluator .evaluate(&AuthzRequest::new( principal, "compute:instances:delete", resource, )) .await .unwrap(); assert!(decision.is_denied()); } #[tokio::test] async fn test_evaluator_deny() { let (binding_store, role_store) = test_stores(); let cache = Arc::new(PolicyCache::default_config()); role_store.init_builtin_roles().await.unwrap(); // No bindings for bob let evaluator = PolicyEvaluator::new(binding_store, role_store, cache); let principal = Principal::new_user("bob", "Bob"); let resource = Resource::new("instance", "vm-1", "org-1", "proj-1"); let req = AuthzRequest::new(principal, "compute:instances:delete", resource); let decision = evaluator.evaluate(&req).await.unwrap(); assert!(decision.is_denied()); } #[tokio::test] async fn test_evaluator_with_condition() { let (binding_store, role_store) = test_stores(); let cache = Arc::new(PolicyCache::default_config()); // Create a custom role with condition // Note: Resource path format is org/{org_id}/project/{project_id}/{kind}/{id} let role = Role::new( "OwnerOnly", Scope::project("*", "*"), vec![ Permission::new("compute:instances:*", "org/*/project/*/instance/*") .with_condition(iam_types::Condition::string_equals( "resource.owner", "${principal.id}", )), ], ); role_store.create_internal(&role).await.unwrap(); // Create binding let alice = PrincipalRef::user("alice"); binding_store .create(&PolicyBinding::new( "b1", alice, "roles/OwnerOnly", Scope::project("proj-1", "org-1"), )) .await .unwrap(); let evaluator = PolicyEvaluator::new(binding_store, role_store, cache); let principal = Principal::new_user("alice", "Alice"); // Alice owns this resource - should be allowed let resource = Resource::new("instance", "vm-1", "org-1", "proj-1").with_owner("alice"); let decision = evaluator .is_allowed(&principal, "compute:instances:delete", &resource) .await .unwrap(); assert!(decision); // Alice doesn't own this resource - should be denied let resource = Resource::new("instance", "vm-2", "org-1", "proj-1").with_owner("bob"); let decision = evaluator .is_allowed(&principal, "compute:instances:delete", &resource) .await .unwrap(); assert!(!decision); } }