432 lines
15 KiB
Rust
Raw Normal View History

2025-12-22 21:34:01 +00:00
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<PathBuf>,
entities_file: Option<PathBuf>,
#[cfg(feature = "cedar")]
policies: Arc<RwLock<Option<PolicySet>>>,
#[cfg(feature = "cedar")]
entities: Arc<RwLock<Option<Entities>>>,
}
impl CedarEvaluator {
/// Create a new Cedar evaluator
pub fn new(policies_dir: Option<PathBuf>, entities_file: Option<PathBuf>) -> 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::<Vec<_>>()
.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::<serde_json::Value>(&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<String, String>>,
) -> AuthResult<AuthDecision> {
// 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);
}
}