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.
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user