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,450 @@
|
||||
/// Integration Tests: Hierarchical RBAC System
|
||||
///
|
||||
/// Tests the new fine-grained access control:
|
||||
/// - Role-based access with rules
|
||||
/// - Project/Visibility/Owner scopes
|
||||
/// - Capability checks (HTTP layer)
|
||||
/// - Resource filtering (retrieval layer)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use mem_cli::rbac::{
|
||||
// Types
|
||||
AccessDecision, AccessRule, AccessScope, Claims, DenyReason,
|
||||
OwnerConstraint, ResourceMeta, ResourceType, Role, Verb, Visibility,
|
||||
// Providers
|
||||
InMemoryRoleProvider, RoleProvider, builtin_role_provider,
|
||||
admin_role, portfolio_agent_role, authenticated_user_role,
|
||||
// Guards
|
||||
AccessGuard, AccessGuardBuilder, InMemoryAuditLogger,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Role Definition Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_admin_role_full_access() {
|
||||
let admin = admin_role();
|
||||
|
||||
// Admin can do everything
|
||||
assert!(admin.allows(ResourceType::Wiki, Verb::Read));
|
||||
assert!(admin.allows(ResourceType::Wiki, Verb::Write));
|
||||
assert!(admin.allows(ResourceType::Wiki, Verb::Delete));
|
||||
assert!(admin.allows(ResourceType::Conversation, Verb::Delete));
|
||||
assert!(admin.allows(ResourceType::Embedding, Verb::Query));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_agent_limited_access() {
|
||||
let agent = portfolio_agent_role();
|
||||
|
||||
// Can read/query wiki and embeddings
|
||||
assert!(agent.allows(ResourceType::Wiki, Verb::Read));
|
||||
assert!(agent.allows(ResourceType::Embedding, Verb::Query));
|
||||
|
||||
// Cannot write/delete wiki
|
||||
assert!(!agent.allows(ResourceType::Wiki, Verb::Write));
|
||||
assert!(!agent.allows(ResourceType::Wiki, Verb::Delete));
|
||||
|
||||
// Can write conversations (own only - scope checked separately)
|
||||
assert!(agent.allows(ResourceType::Conversation, Verb::Write));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authenticated_user_private_access() {
|
||||
let user = authenticated_user_role();
|
||||
|
||||
// Can read all wiki (including private)
|
||||
assert!(user.allows(ResourceType::Wiki, Verb::Read));
|
||||
assert!(user.allows(ResourceType::Skill, Verb::Read));
|
||||
|
||||
// Can manage own conversations
|
||||
assert!(user.allows(ResourceType::Conversation, Verb::Read));
|
||||
assert!(user.allows(ResourceType::Conversation, Verb::Write));
|
||||
assert!(user.allows(ResourceType::Conversation, Verb::Delete));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Custom Role Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_custom_role_project_scoped() {
|
||||
let role = Role::new("homelab-reader")
|
||||
.with_rule(
|
||||
AccessRule::new(vec!["wiki", "embedding"], vec![Verb::Read, Verb::Query])
|
||||
.with_scope(AccessScope::new()
|
||||
.with_projects(vec!["homelab".into()])
|
||||
.with_visibility(Visibility::Public))
|
||||
);
|
||||
|
||||
// Can read wiki in homelab
|
||||
assert!(role.allows(ResourceType::Wiki, Verb::Read));
|
||||
|
||||
// Cannot write
|
||||
assert!(!role.allows(ResourceType::Wiki, Verb::Write));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_role_group_scoped() {
|
||||
let role = Role::new("team-internal")
|
||||
.with_rule(
|
||||
AccessRule::new(vec!["wiki"], vec![Verb::Read, Verb::Write])
|
||||
.with_scope(AccessScope::new()
|
||||
.with_groups(vec!["engineering".into()]))
|
||||
);
|
||||
|
||||
let rules = role.rules_for_resource(ResourceType::Wiki);
|
||||
assert_eq!(rules.len(), 1);
|
||||
|
||||
let scope = &rules[0].scope;
|
||||
assert_eq!(scope.groups, Some(vec!["engineering".into()]));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AccessGuard Capability Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_capability_admin() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("admin-user").with_roles(vec!["admin"]);
|
||||
|
||||
assert!(guard.check_capability(&claims, Verb::Read).await);
|
||||
assert!(guard.check_capability(&claims, Verb::Write).await);
|
||||
assert!(guard.check_capability(&claims, Verb::Delete).await);
|
||||
assert!(guard.check_capability(&claims, Verb::Query).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_capability_portfolio_agent() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
|
||||
|
||||
assert!(guard.check_capability(&claims, Verb::Read).await);
|
||||
assert!(guard.check_capability(&claims, Verb::Query).await);
|
||||
// Has write for conversations
|
||||
assert!(guard.check_capability(&claims, Verb::Write).await);
|
||||
// No delete
|
||||
assert!(!guard.check_capability(&claims, Verb::Delete).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_http_capability_wildcard() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("superuser").with_permissions(vec!["*"]);
|
||||
|
||||
assert!(guard.check_http_capability(&claims, "memory:read").await);
|
||||
assert!(guard.check_http_capability(&claims, "memory:write").await);
|
||||
assert!(guard.check_http_capability(&claims, "anything:else").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_http_capability_direct() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("api-client").with_permissions(vec!["memory:read"]);
|
||||
|
||||
assert!(guard.check_http_capability(&claims, "memory:read").await);
|
||||
assert!(!guard.check_http_capability(&claims, "memory:write").await);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AccessGuard Resource Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_resource_public_wiki() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
|
||||
|
||||
let wiki = ResourceMeta::wiki("doc-1", "homelab")
|
||||
.with_visibility(Visibility::Public);
|
||||
|
||||
assert!(guard.can_read(&claims, &wiki).await);
|
||||
assert!(!guard.can_write(&claims, &wiki).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_resource_private_wiki() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
|
||||
let wiki = ResourceMeta::wiki("secret-doc", "homelab")
|
||||
.with_visibility(Visibility::Private);
|
||||
|
||||
// Portfolio agent cannot read private
|
||||
let visitor = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
|
||||
assert!(!guard.can_read(&visitor, &wiki).await);
|
||||
|
||||
// Authenticated user can
|
||||
let user = Claims::new("alice").with_roles(vec!["authenticated-user"]);
|
||||
assert!(guard.can_read(&user, &wiki).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_resource_wrong_project() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
|
||||
|
||||
let wiki = ResourceMeta::wiki("doc-1", "secret-project")
|
||||
.with_visibility(Visibility::Public);
|
||||
|
||||
// Portfolio agent scope is limited to specific projects
|
||||
assert!(!guard.can_read(&claims, &wiki).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_resource_own_conversation() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
|
||||
|
||||
let own_conv = ResourceMeta::conversation("conv-1", "portfolio", "visitor-123");
|
||||
let other_conv = ResourceMeta::conversation("conv-2", "portfolio", "other-user");
|
||||
|
||||
assert!(guard.can_read(&claims, &own_conv).await);
|
||||
assert!(guard.can_write(&claims, &own_conv).await);
|
||||
|
||||
assert!(!guard.can_read(&claims, &other_conv).await);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter Resources Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_filter_by_visibility() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_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),
|
||||
];
|
||||
|
||||
let result = guard.filter_resources(&claims, Verb::Read, resources).await;
|
||||
|
||||
assert_eq!(result.allowed.len(), 2);
|
||||
assert_eq!(result.denied.len(), 1);
|
||||
|
||||
let allowed_ids: Vec<_> = result.allowed.iter().map(|r| r.id.as_str()).collect();
|
||||
assert!(allowed_ids.contains(&"public-1"));
|
||||
assert!(allowed_ids.contains(&"public-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_filter_mixed_resources() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
|
||||
|
||||
let resources = vec![
|
||||
ResourceMeta::wiki("wiki-1", "homelab").with_visibility(Visibility::Public),
|
||||
ResourceMeta::conversation("conv-own", "portfolio", "visitor-123"),
|
||||
ResourceMeta::conversation("conv-other", "portfolio", "someone-else"),
|
||||
ResourceMeta::skill("skill-1", "homelab"),
|
||||
];
|
||||
|
||||
let result = guard.filter_resources(&claims, Verb::Read, resources).await;
|
||||
|
||||
// Should allow: wiki-1, conv-own
|
||||
// Should deny: conv-other (not owner), skill-1 (portfolio-agent doesn't have skill access)
|
||||
assert_eq!(result.allowed.len(), 2);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Logging Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_audit_logs_access() {
|
||||
let audit = Arc::new(InMemoryAuditLogger::new());
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()))
|
||||
.with_audit(audit.clone());
|
||||
|
||||
let claims = Claims::new("test-user").with_roles(vec!["admin"]);
|
||||
let wiki = ResourceMeta::wiki("doc-1", "homelab");
|
||||
|
||||
guard.check_access(&claims, &wiki, Verb::Read).await;
|
||||
|
||||
let entries = audit.entries();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].user_id, "test-user");
|
||||
assert_eq!(entries[0].decision, "allow");
|
||||
assert_eq!(entries[0].matched_role, Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_audit_logs_denial() {
|
||||
let audit = Arc::new(InMemoryAuditLogger::new());
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()))
|
||||
.with_audit(audit.clone());
|
||||
|
||||
let claims = Claims::new("no-role-user");
|
||||
let wiki = ResourceMeta::wiki("doc-1", "homelab");
|
||||
|
||||
guard.check_access(&claims, &wiki, Verb::Read).await;
|
||||
|
||||
let entries = audit.entries();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].decision, "deny");
|
||||
assert!(entries[0].reason.is_some());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multiple Roles Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_multiple_roles_union() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
|
||||
// User with both portfolio-agent and authenticated-user
|
||||
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);
|
||||
|
||||
// Should be allowed (union of permissions)
|
||||
assert!(guard.can_read(&claims, &private_wiki).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_guard_no_role_denied() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let claims = Claims::new("anonymous"); // No roles
|
||||
|
||||
let wiki = ResourceMeta::wiki("public", "homelab")
|
||||
.with_visibility(Visibility::Public);
|
||||
|
||||
let decision = guard.check_access(&claims, &wiki, Verb::Read).await;
|
||||
assert!(decision.is_denied());
|
||||
|
||||
if let AccessDecision::Deny { reason } = decision {
|
||||
assert!(matches!(reason, DenyReason::NoMatchingRole));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Builder Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builder_with_audit() {
|
||||
let audit = Arc::new(InMemoryAuditLogger::new());
|
||||
|
||||
let guard = AccessGuardBuilder::new()
|
||||
.with_role_provider(Arc::new(builtin_role_provider()))
|
||||
.with_audit(audit.clone())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let claims = Claims::new("user").with_roles(vec!["admin"]);
|
||||
guard.check_capability(&claims, Verb::Read).await;
|
||||
|
||||
assert!(!audit.entries().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builder_missing_provider() {
|
||||
let result = AccessGuardBuilder::new().build();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Custom Provider Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_role_provider() {
|
||||
let provider = Arc::new(
|
||||
InMemoryRoleProvider::new()
|
||||
.add_role(Role::new("custom-reader")
|
||||
.with_rule(AccessRule::new(vec!["wiki"], vec![Verb::Read])))
|
||||
);
|
||||
|
||||
let guard = AccessGuard::new(provider);
|
||||
let claims = Claims::new("user").with_roles(vec!["custom-reader"]);
|
||||
|
||||
let wiki = ResourceMeta::wiki("doc", "any-project");
|
||||
|
||||
assert!(guard.can_read(&claims, &wiki).await);
|
||||
assert!(!guard.can_write(&claims, &wiki).await);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Real-World Scenario Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scenario_portfolio_visitor() {
|
||||
// Simulates a visitor to portfolio site
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let visitor = Claims::new("visitor-abc123").with_roles(vec!["portfolio-agent"]);
|
||||
|
||||
// Can read public docs from allowed projects
|
||||
let public_doc = ResourceMeta::wiki("about-me", "portfolio")
|
||||
.with_visibility(Visibility::Public);
|
||||
assert!(guard.can_read(&visitor, &public_doc).await);
|
||||
|
||||
// Can query embeddings for RAG
|
||||
let embedding = ResourceMeta::embedding("emb-001", "homelab");
|
||||
assert!(guard.can_query(&visitor, &embedding).await);
|
||||
|
||||
// Can chat (own conversations)
|
||||
let conversation = ResourceMeta::conversation("chat-1", "portfolio", "visitor-abc123");
|
||||
assert!(guard.can_write(&visitor, &conversation).await);
|
||||
|
||||
// Cannot access private docs
|
||||
let private_doc = ResourceMeta::wiki("secrets", "homelab")
|
||||
.with_visibility(Visibility::Private);
|
||||
assert!(!guard.can_read(&visitor, &private_doc).await);
|
||||
|
||||
// Cannot access other projects
|
||||
let other_project = ResourceMeta::wiki("internal", "secret-project")
|
||||
.with_visibility(Visibility::Public);
|
||||
assert!(!guard.can_read(&visitor, &other_project).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scenario_authenticated_developer() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let dev = Claims::new("alice")
|
||||
.with_roles(vec!["authenticated-user"])
|
||||
.with_groups(vec!["engineering"]);
|
||||
|
||||
// Can read all wiki including private
|
||||
let private_doc = ResourceMeta::wiki("internal-design", "homelab")
|
||||
.with_visibility(Visibility::Private);
|
||||
assert!(guard.can_read(&dev, &private_doc).await);
|
||||
|
||||
// Can manage own conversations
|
||||
let own_conv = ResourceMeta::conversation("session-1", "homelab", "alice");
|
||||
assert!(guard.can_read(&dev, &own_conv).await);
|
||||
assert!(guard.can_write(&dev, &own_conv).await);
|
||||
assert!(guard.can_delete(&dev, &own_conv).await);
|
||||
|
||||
// Cannot delete others' conversations
|
||||
let other_conv = ResourceMeta::conversation("session-2", "homelab", "bob");
|
||||
assert!(!guard.can_delete(&dev, &other_conv).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scenario_admin_operations() {
|
||||
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
||||
let admin = Claims::new("root").with_roles(vec!["admin"]);
|
||||
|
||||
// Can do anything
|
||||
let any_resource = ResourceMeta::wiki("anything", "any-project")
|
||||
.with_visibility(Visibility::Private)
|
||||
.with_owner("someone-else");
|
||||
|
||||
assert!(guard.can_read(&admin, &any_resource).await);
|
||||
assert!(guard.can_write(&admin, &any_resource).await);
|
||||
assert!(guard.can_delete(&admin, &any_resource).await);
|
||||
|
||||
// Can manage others' conversations
|
||||
let other_conv = ResourceMeta::conversation("session", "any", "anyone");
|
||||
assert!(guard.can_delete(&admin, &other_conv).await);
|
||||
}
|
||||
Reference in New Issue
Block a user