Files
poimen-memory/tests/it_rbac_hierarchical.rs
rock 2448e5ebe2 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

451 lines
17 KiB
Rust

/// 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);
}