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.
470 lines
16 KiB
Rust
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
|
|
}
|
|
}
|