637 lines
19 KiB
Rust
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);
|
|
}
|
|
}
|