434 lines
14 KiB
Rust
434 lines
14 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
#[cfg(feature = "cedar")]
|
|
use {
|
|
cedar_policy::{Authorizer, Entities, PolicySet},
|
|
std::sync::{Arc, RwLock},
|
|
};
|
|
|
|
use crate::error::{AuthError, AuthResult};
|
|
|
|
/// 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)),
|
|
}
|
|
}
|
|
|
|
/// Helper function to read and validate a single Cedar policy file
|
|
fn read_cedar_policy_file(path: &std::path::Path) -> AuthResult<Option<(String, String)>> {
|
|
let is_cedar = path.extension().and_then(|ext| ext.to_str()) == Some("cedar");
|
|
if !is_cedar {
|
|
return Ok(None);
|
|
}
|
|
|
|
let policy_content = std::fs::read_to_string(path).map_err(|e| {
|
|
AuthError::CedarPolicy(format!(
|
|
"Failed to read policy file {}: {}",
|
|
path.display(),
|
|
e
|
|
))
|
|
})?;
|
|
|
|
Ok(Some((path.display().to_string(), policy_content)))
|
|
}
|
|
|
|
/// Load policies from the configured directory
|
|
pub fn load_policies(&self) -> AuthResult<()> {
|
|
let dir = match &self.policies_dir {
|
|
Some(d) => d,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
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 all_policies: Result<Vec<_>, AuthError> = entries
|
|
.map(|entry| {
|
|
let entry = entry.map_err(|e| {
|
|
AuthError::CedarPolicy(format!("Failed to read policy entry: {}", e))
|
|
})?;
|
|
Self::read_cedar_policy_file(&entry.path())
|
|
})
|
|
.collect();
|
|
|
|
let all_policies: Vec<_> = all_policies?.into_iter().flatten().collect();
|
|
|
|
if all_policies.is_empty() {
|
|
return Err(AuthError::CedarPolicy(
|
|
"No Cedar policies found in configured directory".to_string(),
|
|
));
|
|
}
|
|
|
|
let combined = all_policies
|
|
.iter()
|
|
.map(|(_, content)| content.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
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 policy_count: Result<usize, AuthError> = entries
|
|
.map(|entry| {
|
|
let entry = entry.map_err(|e| {
|
|
AuthError::CedarPolicy(format!("Failed to read policy entry: {}", e))
|
|
})?;
|
|
Ok(Self::read_cedar_policy_file(&entry.path())?.is_some() as usize)
|
|
})
|
|
.sum();
|
|
|
|
if policy_count? == 0 {
|
|
return Err(AuthError::CedarPolicy(
|
|
"No Cedar policies found in configured directory".to_string(),
|
|
));
|
|
}
|
|
|
|
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 std::fs;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
|
|
#[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);
|
|
}
|
|
}
|