Files
poimen-memory/crates/mem-cli/src/rbac/access_evaluator.rs
T
rock 56f8e8b391 feat(rbac): hierarchical access control with fine-grained scopes
Implements comprehensive RBAC system:

Core Types (types.rs):
- Role: named set of AccessRules
- AccessRule: (resources, verbs, scope) tuple
- AccessScope: project/visibility/owner/group constraints
- ResourceMeta: document metadata for access checks
- Verb: read/write/delete/query
- Visibility: public/private per document

Role Provider (role_provider.rs):
- RoleProvider trait for pluggable backends
- YamlRoleProvider: load from YAML files
- InMemoryRoleProvider: for testing
- CompositeRoleProvider: layered lookup
- Built-in roles: admin, portfolio-agent, authenticated-user

Scope Checker (scope_checker.rs):
- ScopeChecker trait + composite pattern
- ProjectScopeChecker: allowed projects list
- VisibilityScopeChecker: public/private matching
- OwnerScopeChecker: self/any/specific user
- GroupScopeChecker: required group membership

Access Guard (access_guard.rs):
- Unified API for HTTP + retrieval layers
- check_http_capability(): memory:read/write checks
- filter_resources(): document-level filtering
- Audit logging for all decisions

Tests: 77 unit + 25 integration, all passing

Migration note: AuthorizedPipeline retained for compatibility,
will be replaced by AccessGuard integration in next phase.
2026-08-31 23:22:11 -07:00

470 lines
16 KiB
Rust

/// Access Evaluator: Orchestrate all access checks
///
/// Flow:
/// 1. Resolve roles from claims
/// 2. For each role, find matching rules
/// 3. For each rule, check verb + scope
/// 4. First match wins (allow), else deny
use std::sync::Arc;
use super::role_provider::RoleProvider;
use super::scope_checker::{CompositeScopeChecker, ScopeResult};
use super::types::{AccessDecision, Claims, DenyReason, ResourceMeta, Role, Verb};
pub use super::types::HasResourceMeta;
// ============================================================================
// Access Evaluator
// ============================================================================
/// Evaluates access requests against roles and scopes
pub struct AccessEvaluator {
role_provider: Arc<dyn RoleProvider>,
scope_checker: CompositeScopeChecker,
}
impl AccessEvaluator {
pub fn new(role_provider: Arc<dyn RoleProvider>) -> Self {
Self {
role_provider,
scope_checker: CompositeScopeChecker::new(),
}
}
/// Custom scope checker
pub fn with_scope_checker(mut self, checker: CompositeScopeChecker) -> Self {
self.scope_checker = checker;
self
}
/// Evaluate access for a single resource
pub async fn evaluate(
&self,
claims: &Claims,
resource: &ResourceMeta,
verb: Verb,
) -> AccessDecision {
// Get all roles for this user
let roles = match self.role_provider.get_roles(&claims.roles).await {
Ok(roles) => roles,
Err(_) => return AccessDecision::Deny { reason: DenyReason::NoMatchingRole },
};
if roles.is_empty() {
return AccessDecision::Deny { reason: DenyReason::NoMatchingRole };
}
// Check each role
for role in &roles {
if let Some(decision) = self.evaluate_role(claims, resource, verb, role) {
if decision.is_allowed() {
return decision;
}
}
}
AccessDecision::Deny { reason: DenyReason::NoMatchingRule }
}
/// Evaluate a single role
fn evaluate_role(
&self,
claims: &Claims,
resource: &ResourceMeta,
verb: Verb,
role: &Role,
) -> Option<AccessDecision> {
// Find rules that match the resource type
let matching_rules = role.rules_for_resource(resource.resource_type);
for (rule_idx, rule) in matching_rules.iter().enumerate() {
// Check verb
if !rule.allows_verb(verb) {
continue;
}
// Check scope
match self.scope_checker.check_all(claims, resource, &rule.scope) {
ScopeResult::Pass | ScopeResult::NotApplicable => {
return Some(AccessDecision::Allow {
matched_role: role.name.clone(),
matched_rule_index: rule_idx,
});
}
ScopeResult::Fail(_) => continue,
}
}
None
}
/// Evaluate capability-level access (without specific resource)
/// Used for HTTP endpoint checks like "can user read anything?"
pub async fn evaluate_capability(&self, claims: &Claims, verb: Verb) -> bool {
let roles = match self.role_provider.get_roles(&claims.roles).await {
Ok(roles) => roles,
Err(_) => return false,
};
for role in &roles {
for rule in &role.rules {
if rule.allows_verb(verb) {
return true;
}
}
}
false
}
/// Filter resources by access
pub async fn filter<T: HasResourceMeta>(
&self,
claims: &Claims,
verb: Verb,
resources: Vec<T>,
) -> FilterResult<T> {
let mut allowed = Vec::new();
let mut denied = Vec::new();
for resource in resources {
let meta = resource.resource_meta();
match self.evaluate(claims, meta, verb).await {
AccessDecision::Allow { .. } => allowed.push(resource),
AccessDecision::Deny { reason } => {
denied.push((meta.id.clone(), reason));
}
}
}
FilterResult { allowed, denied }
}
/// Batch evaluation for multiple resources (optimized)
pub async fn evaluate_batch(
&self,
claims: &Claims,
resources: &[ResourceMeta],
verb: Verb,
) -> Vec<AccessDecision> {
// Pre-fetch roles once
let roles = match self.role_provider.get_roles(&claims.roles).await {
Ok(roles) => roles,
Err(_) => {
return resources
.iter()
.map(|_| AccessDecision::Deny { reason: DenyReason::NoMatchingRole })
.collect();
}
};
if roles.is_empty() {
return resources
.iter()
.map(|_| AccessDecision::Deny { reason: DenyReason::NoMatchingRole })
.collect();
}
resources
.iter()
.map(|resource| {
for role in &roles {
if let Some(decision) = self.evaluate_role(claims, resource, verb, role) {
if decision.is_allowed() {
return decision;
}
}
}
AccessDecision::Deny { reason: DenyReason::NoMatchingRule }
})
.collect()
}
}
/// Result of filtering resources by access
#[derive(Debug)]
pub struct FilterResult<T> {
pub allowed: Vec<T>,
pub denied: Vec<(String, DenyReason)>,
}
impl<T> FilterResult<T> {
pub fn all_allowed(&self) -> bool {
self.denied.is_empty()
}
pub fn none_allowed(&self) -> bool {
self.allowed.is_empty()
}
pub fn stats(&self) -> (usize, usize) {
(self.allowed.len(), self.denied.len())
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::rbac::role_provider::{admin_role, authenticated_user_role, portfolio_agent_role, InMemoryRoleProvider};
use crate::rbac::types::{AccessRule, AccessScope, ResourceType, Visibility};
fn test_provider() -> Arc<InMemoryRoleProvider> {
Arc::new(
InMemoryRoleProvider::new()
.add_role(admin_role())
.add_role(portfolio_agent_role())
.add_role(authenticated_user_role())
)
}
// ========================================================================
// Admin Tests
// ========================================================================
#[tokio::test]
async fn test_admin_full_access() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("root").with_roles(vec!["admin"]);
let wiki = ResourceMeta::wiki("doc-1", "secret-project")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &wiki, Verb::Write).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_admin_delete_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("root").with_roles(vec!["admin"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "other-user");
let result = evaluator.evaluate(&claims, &conv, Verb::Delete).await;
assert!(result.is_allowed());
}
// ========================================================================
// Portfolio Agent Tests
// ========================================================================
#[tokio::test]
async fn test_portfolio_agent_read_public_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_portfolio_agent_denied_private_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("secret", "homelab")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
}
#[tokio::test]
async fn test_portfolio_agent_denied_wrong_project() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "secret-project")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
}
#[tokio::test]
async fn test_portfolio_agent_own_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "visitor-123");
let result = evaluator.evaluate(&claims, &conv, Verb::Write).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_portfolio_agent_denied_other_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "other-user");
let result = evaluator.evaluate(&claims, &conv, Verb::Read).await;
assert!(result.is_denied());
}
#[tokio::test]
async fn test_portfolio_agent_denied_write_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Write).await;
assert!(result.is_denied());
}
// ========================================================================
// Authenticated User Tests
// ========================================================================
#[tokio::test]
async fn test_auth_user_read_any_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("alice").with_roles(vec!["authenticated-user"]);
// Can read private wiki in any project
let wiki = ResourceMeta::wiki("secret", "secret-project")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_auth_user_own_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("alice").with_roles(vec!["authenticated-user"]);
let conv = ResourceMeta::conversation("conv-1", "any-project", "alice");
// Can read, write, delete own conversations
assert!(evaluator.evaluate(&claims, &conv, Verb::Read).await.is_allowed());
assert!(evaluator.evaluate(&claims, &conv, Verb::Write).await.is_allowed());
assert!(evaluator.evaluate(&claims, &conv, Verb::Delete).await.is_allowed());
}
// ========================================================================
// No Role Tests
// ========================================================================
#[tokio::test]
async fn test_no_role_denied() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("anonymous"); // No roles
let wiki = ResourceMeta::wiki("public", "homelab")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
assert!(matches!(result, AccessDecision::Deny { reason: DenyReason::NoMatchingRole }));
}
#[tokio::test]
async fn test_unknown_role_denied() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("user").with_roles(vec!["nonexistent-role"]);
let wiki = ResourceMeta::wiki("public", "homelab");
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
}
// ========================================================================
// Capability Tests
// ========================================================================
#[tokio::test]
async fn test_capability_admin_can_write() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("root").with_roles(vec!["admin"]);
assert!(evaluator.evaluate_capability(&claims, Verb::Write).await);
assert!(evaluator.evaluate_capability(&claims, Verb::Delete).await);
}
#[tokio::test]
async fn test_capability_portfolio_agent_read_only() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
assert!(evaluator.evaluate_capability(&claims, Verb::Read).await);
assert!(evaluator.evaluate_capability(&claims, Verb::Query).await);
assert!(evaluator.evaluate_capability(&claims, Verb::Write).await); // Has write for conversations
assert!(!evaluator.evaluate_capability(&claims, Verb::Delete).await);
}
// ========================================================================
// Filter Tests
// ========================================================================
#[tokio::test]
async fn test_filter_resources() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let resources = vec![
ResourceMeta::wiki("public-1", "homelab").with_visibility(Visibility::Public),
ResourceMeta::wiki("private-1", "homelab").with_visibility(Visibility::Private),
ResourceMeta::wiki("public-2", "portfolio").with_visibility(Visibility::Public),
ResourceMeta::wiki("wrong-project", "secret").with_visibility(Visibility::Public),
];
let result = evaluator.filter(&claims, Verb::Read, resources).await;
assert_eq!(result.allowed.len(), 2); // public-1, public-2
assert_eq!(result.denied.len(), 2); // private-1, wrong-project
}
// ========================================================================
// Batch Evaluation Tests
// ========================================================================
#[tokio::test]
async fn test_batch_evaluation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let resources = vec![
ResourceMeta::wiki("public", "homelab").with_visibility(Visibility::Public),
ResourceMeta::wiki("private", "homelab").with_visibility(Visibility::Private),
];
let decisions = evaluator.evaluate_batch(&claims, &resources, Verb::Read).await;
assert_eq!(decisions.len(), 2);
assert!(decisions[0].is_allowed());
assert!(decisions[1].is_denied());
}
// ========================================================================
// Multiple Roles Tests
// ========================================================================
#[tokio::test]
async fn test_multiple_roles_combined() {
let evaluator = AccessEvaluator::new(test_provider());
// User with both portfolio-agent and authenticated-user roles
let claims = Claims::new("power-user")
.with_roles(vec!["portfolio-agent", "authenticated-user"]);
// Private wiki - denied by portfolio-agent, allowed by authenticated-user
let private_wiki = ResourceMeta::wiki("secret", "homelab")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &private_wiki, Verb::Read).await;
assert!(result.is_allowed()); // authenticated-user allows it
}
}