451 lines
17 KiB
Rust
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);
|
||
|
|
}
|