use std::collections::HashMap; use std::path::PathBuf; use crate::error::{AuthError, AuthResult}; #[cfg(feature = "cedar")] use { cedar_policy::{Authorizer, Entities, PolicySet}, std::sync::{Arc, RwLock}, }; /// Authorization decision result #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthDecision { Permit, Forbid, } impl AuthDecision { pub fn is_allowed(&self) -> bool { matches!(self, AuthDecision::Permit) } } /// Cedar policy evaluator for ABAC (Attribute-Based Access Control) pub struct CedarEvaluator { policies_dir: Option, entities_file: Option, #[cfg(feature = "cedar")] policies: Arc>>, #[cfg(feature = "cedar")] entities: Arc>>, } impl CedarEvaluator { /// Create a new Cedar evaluator pub fn new(policies_dir: Option, entities_file: Option) -> Self { Self { policies_dir, entities_file, #[cfg(feature = "cedar")] policies: Arc::new(RwLock::new(None)), #[cfg(feature = "cedar")] entities: Arc::new(RwLock::new(None)), } } /// Load policies from the configured directory pub fn load_policies(&self) -> AuthResult<()> { if let Some(dir) = &self.policies_dir { if !dir.exists() { return Err(AuthError::CedarPolicy(format!( "Policies directory not found: {}", dir.display() ))); } let entries = std::fs::read_dir(dir).map_err(|e| { AuthError::CedarPolicy(format!("Failed to read policies dir: {}", e)) })?; #[cfg(feature = "cedar")] { use std::str::FromStr; let mut all_policies = Vec::new(); let mut policy_count = 0; for entry in entries { let entry = entry.map_err(|e| { AuthError::CedarPolicy(format!("Failed to read policy entry: {}", e)) })?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) == Some("cedar") { let policy_content = std::fs::read_to_string(&path).map_err(|e| { AuthError::CedarPolicy(format!( "Failed to read policy file {}: {}", path.display(), e )) })?; all_policies.push((path.display().to_string(), policy_content)); policy_count += 1; } } if policy_count == 0 { return Err(AuthError::CedarPolicy( "No Cedar policies found in configured directory".to_string(), )); } // Combine all policy files let combined = all_policies .iter() .map(|(_, content)| content.as_str()) .collect::>() .join("\n"); // Parse policies from Cedar syntax let policy_set = PolicySet::from_str(&combined).map_err(|e| { AuthError::CedarPolicy(format!("Failed to parse Cedar policies: {}", e)) })?; *self.policies.write().unwrap() = Some(policy_set); } #[cfg(not(feature = "cedar"))] { let mut policy_count = 0; for entry in entries { let entry = entry.map_err(|e| { AuthError::CedarPolicy(format!("Failed to read policy entry: {}", e)) })?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) == Some("cedar") { let _policy_content = std::fs::read_to_string(&path).map_err(|e| { AuthError::CedarPolicy(format!( "Failed to read policy file {}: {}", path.display(), e )) })?; policy_count += 1; } } if policy_count == 0 { return Err(AuthError::CedarPolicy( "No Cedar policies found in configured directory".to_string(), )); } // Without cedar feature, we can only validate files exist tracing::warn!("Cedar feature not enabled - policy evaluation will not work. Compile with --features cedar"); } } Ok(()) } /// Load entities from the configured JSON file pub fn load_entities(&self) -> AuthResult<()> { if let Some(file) = &self.entities_file { if !file.exists() { return Err(AuthError::CedarPolicy(format!( "Entities file not found: {}", file.display() ))); } let entities_content = std::fs::read_to_string(file).map_err(|e| { AuthError::CedarPolicy(format!("Failed to read entities file: {}", e)) })?; #[cfg(feature = "cedar")] { // Parse JSON entities let json_value: serde_json::Value = serde_json::from_str(&entities_content) .map_err(|e| { AuthError::CedarPolicy(format!("Failed to parse entities JSON: {}", e)) })?; // Convert to Cedar entities from JSON (without schema validation) let entities = Entities::from_json_value(json_value, None).map_err(|e| { AuthError::CedarPolicy(format!( "Failed to convert entities to Cedar format: {}", e )) })?; *self.entities.write().unwrap() = Some(entities); } #[cfg(not(feature = "cedar"))] { // Without cedar feature, just validate JSON is well-formed serde_json::from_str::(&entities_content).map_err(|e| { AuthError::CedarPolicy(format!("Invalid JSON in entities file: {}", e)) })?; tracing::warn!("Cedar feature not enabled - entity store will not be populated"); } } Ok(()) } /// Evaluate a policy decision /// /// Arguments: /// - principal: entity making the request (e.g., "user::alice") /// - action: action being requested (e.g., "Action::read") /// - resource: resource being accessed (e.g., "Secret::database_password") /// - context: additional context for decision (e.g., IP address, MFA status) pub fn evaluate( &self, principal: &str, action: &str, resource: &str, context: Option<&HashMap>, ) -> AuthResult { // Note: principal, action, resource, context are used in cedar feature, unused without #[allow(unused_variables)] let _ = (principal, action, resource, context); #[cfg(feature = "cedar")] { use std::str::FromStr; // Check if policies are loaded let policies = self.policies.read().unwrap(); if policies.is_none() { // No policies configured - permit all return Ok(AuthDecision::Permit); } let policy_set = policies.as_ref().unwrap(); // Get entities or use empty let entities_lock = self.entities.read().unwrap(); let empty_entities = Entities::empty(); let entities = entities_lock.as_ref().unwrap_or(&empty_entities); // Parse entity references from strings let principal_ref = cedar_policy::EntityUid::from_str(principal).map_err(|e| { AuthError::CedarPolicy(format!("Invalid principal format '{}': {}", principal, e)) })?; let action_ref = cedar_policy::EntityUid::from_str(action).map_err(|e| { AuthError::CedarPolicy(format!("Invalid action format '{}': {}", action, e)) })?; let resource_ref = cedar_policy::EntityUid::from_str(resource).map_err(|e| { AuthError::CedarPolicy(format!("Invalid resource format '{}': {}", resource, e)) })?; // Build context object let mut context_obj = serde_json::json!({}); if let Some(ctx) = context { for (key, value) in ctx { context_obj[key] = serde_json::json!(value); } } // Create context from the JSON object (schema-less, no request context info) let context_value = cedar_policy::Context::from_json_value(context_obj, None) .map_err(|e| AuthError::CedarPolicy(format!("Failed to build context: {}", e)))?; // Build authorization request with schema-less evaluation let request = cedar_policy::Request::new( principal_ref, action_ref, resource_ref, context_value, None, // schema: no schema validation required for basic evaluation ) .map_err(|e| { AuthError::CedarPolicy(format!("Failed to build authorization request: {}", e)) })?; // Create authorizer and evaluate let authorizer = Authorizer::new(); let response = authorizer.is_authorized(&request, policy_set, entities); match response.decision() { cedar_policy::Decision::Allow => Ok(AuthDecision::Permit), cedar_policy::Decision::Deny => Ok(AuthDecision::Forbid), } } #[cfg(not(feature = "cedar"))] { // Without cedar feature, check if policies are configured if self.policies_dir.is_some() || self.entities_file.is_some() { tracing::warn!("Cedar policies configured but cedar feature not enabled"); } // Permit by default when cedar feature is not enabled Ok(AuthDecision::Permit) } } /// Check if policies are configured pub fn is_configured(&self) -> bool { self.policies_dir.is_some() || self.entities_file.is_some() } } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn test_cedar_evaluator_creation() { let evaluator = CedarEvaluator::new(None, None); assert!(!evaluator.is_configured()); } #[test] fn test_cedar_evaluator_with_paths() { let temp_dir = TempDir::new().unwrap(); let evaluator = CedarEvaluator::new(Some(temp_dir.path().to_path_buf()), None); assert!(evaluator.is_configured()); } #[test] fn test_missing_policies_dir() { let evaluator = CedarEvaluator::new(Some(PathBuf::from("/nonexistent/path")), None); let result = evaluator.load_policies(); assert!(result.is_err()); } #[test] fn test_empty_policies_dir() { let temp_dir = TempDir::new().unwrap(); let evaluator = CedarEvaluator::new(Some(temp_dir.path().to_path_buf()), None); let result = evaluator.load_policies(); assert!(result.is_err()); } #[test] fn test_default_permit_decision() { let evaluator = CedarEvaluator::new(None, None); let decision = evaluator .evaluate("User::alice", "Action::read", "Secret::db_password", None) .unwrap(); assert_eq!(decision, AuthDecision::Permit); } #[test] fn test_load_valid_cedar_policies() { let temp_dir = TempDir::new().unwrap(); let policy_file = temp_dir.path().join("allow_read.cedar"); // Create a simple Cedar policy let policy_content = r#" permit (principal, action, resource) when { action == Action::"read" }; "#; fs::write(&policy_file, policy_content).unwrap(); let evaluator = CedarEvaluator::new(Some(temp_dir.path().to_path_buf()), None); let result = evaluator.load_policies(); #[cfg(feature = "cedar")] assert!(result.is_ok()); #[cfg(not(feature = "cedar"))] assert!(result.is_ok()); } #[test] fn test_load_valid_entities_json() { let temp_dir = TempDir::new().unwrap(); let entities_file = temp_dir.path().join("entities.json"); // Create a Cedar entities JSON in the correct format let entities_content = r#"{ "": [ { "uid": {"type": "User", "id": "alice"}, "attrs": {} } ] }"#; fs::write(&entities_file, entities_content).unwrap(); let evaluator = CedarEvaluator::new(None, Some(entities_file)); let result = evaluator.load_entities(); // Result may fail with Cedar validation but should succeed in parsing JSON #[cfg(feature = "cedar")] { // May succeed or fail depending on Cedar's validation let _ = result; } #[cfg(not(feature = "cedar"))] assert!(result.is_ok()); } #[test] fn test_invalid_entities_json() { let temp_dir = TempDir::new().unwrap(); let entities_file = temp_dir.path().join("entities.json"); // Create invalid JSON fs::write(&entities_file, "{ invalid json ").unwrap(); let evaluator = CedarEvaluator::new(None, Some(entities_file)); let result = evaluator.load_entities(); assert!(result.is_err()); } #[test] fn test_missing_entities_file() { let evaluator = CedarEvaluator::new(None, Some(PathBuf::from("/nonexistent/entities.json"))); let result = evaluator.load_entities(); assert!(result.is_err()); } #[test] fn test_context_in_evaluation() { let evaluator = CedarEvaluator::new(None, None); let mut context = HashMap::new(); context.insert("ip_address".to_string(), "192.168.1.1".to_string()); context.insert("mfa_verified".to_string(), "true".to_string()); let decision = evaluator .evaluate( "User::alice", "Action::read", "Secret::db_password", Some(&context), ) .unwrap(); // Without policies, always permit assert_eq!(decision, AuthDecision::Permit); } }