photoncloud-monorepo/iam/crates/iam-authz/src/evaluator.rs

637 lines
19 KiB
Rust

//! 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<String>, 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<String>,
/// Matched role reference (if allowed)
pub matched_role: Option<String>,
}
impl AuthzEvaluation {
fn allow(binding_id: impl Into<String>, role_ref: impl Into<String>) -> Self {
Self {
decision: AuthzDecision::Allow,
matched_binding: Some(binding_id.into()),
matched_role: Some(role_ref.into()),
}
}
fn deny(reason: impl Into<String>) -> 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<BindingStore>,
role_store: Arc<RoleStore>,
group_store: Option<Arc<GroupStore>>,
cache: Arc<PolicyCache>,
config: PolicyEvaluatorConfig,
}
impl PolicyEvaluator {
/// Create a new policy evaluator
pub fn new(
binding_store: Arc<BindingStore>,
role_store: Arc<RoleStore>,
cache: Arc<PolicyCache>,
) -> 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<BindingStore>,
role_store: Arc<RoleStore>,
group_store: Arc<GroupStore>,
cache: Arc<PolicyCache>,
) -> 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<AuthzDecision> {
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<AuthzEvaluation> {
// 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<bool> {
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<bool> {
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<bool> {
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<Vec<PolicyBinding>> {
// 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<Option<Role>> {
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<BindingStore>, Arc<RoleStore>) {
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);
}
}