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,572 @@
|
||||
/// Integration Tests: Authorized Pipeline (RBAC + JWT)
|
||||
///
|
||||
/// Tests two-level access control:
|
||||
/// 1. Capability level: memory:read / memory:write (checked at HTTP layer)
|
||||
/// 2. Resource level: project/skill policies (checked in AuthorizedPipeline)
|
||||
///
|
||||
/// Access flow:
|
||||
/// JWT → validate → check project access → execute pipeline → filter by document access
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use mem_core::{GlobalTfIdfScorer, SemanticScorer};
|
||||
use mem_ingest::wiki_link::WikiLinkGraph;
|
||||
use mem_cli::{
|
||||
AuthorizedPipeline, AuthorizedPipelineBuilder, AccessStats,
|
||||
FullPipeline, PipelineConfig,
|
||||
rbac::{AccessPolicy, MockPolicyProvider, OidcClaims, LegacyNoOpAuditLogger as NoOpAuditLogger},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Test Fixtures
|
||||
// ============================================================================
|
||||
|
||||
fn create_test_vocab() -> Arc<BTreeMap<String, f32>> {
|
||||
let mut vocab = BTreeMap::new();
|
||||
vocab.insert("kubernetes".to_string(), 0.8);
|
||||
vocab.insert("pod".to_string(), 0.7);
|
||||
vocab.insert("secret".to_string(), 0.9);
|
||||
Arc::new(vocab)
|
||||
}
|
||||
|
||||
fn create_test_pipeline() -> FullPipeline {
|
||||
let vocab = create_test_vocab();
|
||||
let tfidf = Arc::new(GlobalTfIdfScorer::new(vocab));
|
||||
let semantic = Arc::new(SemanticScorer::new());
|
||||
FullPipeline::new(tfidf, semantic, PipelineConfig::default())
|
||||
}
|
||||
|
||||
fn create_test_wiki_graph() -> WikiLinkGraph {
|
||||
let mut graph = WikiLinkGraph::new("test");
|
||||
graph.add_link("index.md", "docs/public.md");
|
||||
graph.add_link("index.md", "docs/internal.md");
|
||||
graph.add_link("docs/internal.md", "shared/skills/SKILL-secret/SKILL.md");
|
||||
graph
|
||||
}
|
||||
|
||||
fn create_test_candidates() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("index.md".to_string(), "# Index\nKubernetes documentation.".to_string()),
|
||||
("docs/public.md".to_string(), "# Public Docs\nPublic kubernetes guide.".to_string()),
|
||||
("docs/internal.md".to_string(), "# Internal\nInternal pod documentation.".to_string()),
|
||||
("shared/skills/SKILL-secret/SKILL.md".to_string(), "# Secret Skill\nConfidential deployment skill.".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
// Policy helpers
|
||||
fn public_policy() -> AccessPolicy {
|
||||
AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_policy(groups: Vec<&str>) -> AccessPolicy {
|
||||
AccessPolicy {
|
||||
access_level: "group".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: groups.into_iter().map(String::from).collect(),
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn private_policy(owner: &str) -> AccessPolicy {
|
||||
AccessPolicy {
|
||||
access_level: "private".to_string(),
|
||||
owner_group: owner.to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn role_policy(role: &str) -> AccessPolicy {
|
||||
AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: Some(role.to_string()),
|
||||
required_permission: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_policy(perm: &str) -> AccessPolicy {
|
||||
AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: Some(perm.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Access Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_public_project_anyone_can_access() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "docs", public_policy())
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// Anyone can access public project
|
||||
let claims = OidcClaims {
|
||||
sub: "anonymous".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let allowed = auth.check_project_access(&claims, "docs").await.unwrap();
|
||||
assert!(allowed, "Public project should allow anyone");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_group_project_member_can_access() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "internal", group_policy(vec!["engineering", "devops"]))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// Engineering member can access
|
||||
let eng_claims = OidcClaims {
|
||||
sub: "alice".to_string(),
|
||||
groups: vec!["engineering".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(auth.check_project_access(&eng_claims, "internal").await.unwrap());
|
||||
|
||||
// DevOps member can access
|
||||
let devops_claims = OidcClaims {
|
||||
sub: "bob".to_string(),
|
||||
groups: vec!["devops".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(auth.check_project_access(&devops_claims, "internal").await.unwrap());
|
||||
|
||||
// Sales cannot access
|
||||
let sales_claims = OidcClaims {
|
||||
sub: "charlie".to_string(),
|
||||
groups: vec!["sales".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(!auth.check_project_access(&sales_claims, "internal").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_private_project_owner_only() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "secret", private_policy("ml-team"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// Owner group can access
|
||||
let owner_claims = OidcClaims {
|
||||
sub: "ml-researcher".to_string(),
|
||||
groups: vec!["ml-team".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(auth.check_project_access(&owner_claims, "secret").await.unwrap());
|
||||
|
||||
// Non-owner denied
|
||||
let other_claims = OidcClaims {
|
||||
sub: "engineer".to_string(),
|
||||
groups: vec!["engineering".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(!auth.check_project_access(&other_claims, "secret").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_role_required_project() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "admin-only", role_policy("admin"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// Admin can access
|
||||
let admin_claims = OidcClaims {
|
||||
sub: "admin-user".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec!["admin".to_string()],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(auth.check_project_access(&admin_claims, "admin-only").await.unwrap());
|
||||
|
||||
// Viewer cannot access
|
||||
let viewer_claims = OidcClaims {
|
||||
sub: "viewer-user".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec!["viewer".to_string()],
|
||||
permissions: vec![],
|
||||
};
|
||||
assert!(!auth.check_project_access(&viewer_claims, "admin-only").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_permission_required_project() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "special", permission_policy("project:special:read"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// User with permission can access
|
||||
let permitted_claims = OidcClaims {
|
||||
sub: "special-user".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec!["project:special:read".to_string()],
|
||||
};
|
||||
assert!(auth.check_project_access(&permitted_claims, "special").await.unwrap());
|
||||
|
||||
// User without permission denied
|
||||
let regular_claims = OidcClaims {
|
||||
sub: "regular-user".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec!["memory:read".to_string()],
|
||||
};
|
||||
assert!(!auth.check_project_access(®ular_claims, "special").await.unwrap());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document/Skill Filtering Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_skill_filtered_by_access() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "docs", public_policy())
|
||||
.with_policy("skill", "SKILL-secret", private_policy("ml-team"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "engineer".to_string(),
|
||||
groups: vec!["engineering".to_string()], // Not ml-team
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let graph = create_test_wiki_graph();
|
||||
let candidates = create_test_candidates();
|
||||
|
||||
let result = auth
|
||||
.execute_with_claims(&claims, "kubernetes", "docs", &graph, candidates)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Project access allowed
|
||||
assert!(result.access_stats.project_access_allowed);
|
||||
|
||||
// SKILL-secret should be filtered out
|
||||
let has_secret_skill = result.result.chunks
|
||||
.iter()
|
||||
.any(|c| c.id.contains("SKILL-secret"));
|
||||
assert!(!has_secret_skill, "Secret skill should be filtered out");
|
||||
|
||||
// Should have some denied chunks
|
||||
assert!(result.access_stats.chunks_denied >= 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_skill_visible_to_owner() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "docs", public_policy())
|
||||
.with_policy("skill", "SKILL-secret", private_policy("ml-team"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// ML team member should see the skill
|
||||
let claims = OidcClaims {
|
||||
sub: "ml-researcher".to_string(),
|
||||
groups: vec!["ml-team".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let graph = create_test_wiki_graph();
|
||||
let candidates = create_test_candidates();
|
||||
|
||||
let result = auth
|
||||
.execute_with_claims(&claims, "secret skill", "docs", &graph, candidates)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// No chunks should be denied for owner
|
||||
assert_eq!(result.access_stats.chunks_denied, 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// End-to-End Pipeline Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_pipeline_with_access_control() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "poimen", group_policy(vec!["platform-team"]))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "platform-engineer".to_string(),
|
||||
groups: vec!["platform-team".to_string()],
|
||||
roles: vec!["engineer".to_string()],
|
||||
permissions: vec!["memory:read".to_string(), "memory:write".to_string()],
|
||||
};
|
||||
|
||||
let graph = create_test_wiki_graph();
|
||||
let candidates = create_test_candidates();
|
||||
|
||||
let result = auth
|
||||
.execute_with_claims(&claims, "kubernetes pod", "poimen", &graph, candidates)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Access stats should be populated
|
||||
assert!(result.access_stats.project_access_checked);
|
||||
assert!(result.access_stats.project_access_allowed);
|
||||
assert!(result.access_stats.chunks_before_filter >= 0);
|
||||
|
||||
// User ID should be captured
|
||||
assert_eq!(result.user_id, "platform-engineer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_denied_project_returns_error() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "secret", private_policy("ml-team"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "outsider".to_string(),
|
||||
groups: vec!["other-team".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let graph = create_test_wiki_graph();
|
||||
let candidates = create_test_candidates();
|
||||
|
||||
let result = auth
|
||||
.execute_with_claims(&claims, "query", "secret", &graph, candidates)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("Access denied"));
|
||||
assert!(err.contains("secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_direct_execution_with_access() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "public-docs", public_policy())
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "anyone".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let candidates = create_test_candidates();
|
||||
|
||||
let result = auth
|
||||
.execute_direct_with_claims(&claims, "kubernetes", "public-docs", candidates)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.access_stats.project_access_allowed);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Builder Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builder_full_config() {
|
||||
let vocab = create_test_vocab();
|
||||
let tfidf = Arc::new(GlobalTfIdfScorer::new(vocab));
|
||||
let semantic = Arc::new(SemanticScorer::new());
|
||||
let provider = Arc::new(MockPolicyProvider::new());
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
|
||||
let auth = AuthorizedPipelineBuilder::new()
|
||||
.with_scorers(tfidf, semantic)
|
||||
.with_policy_provider(provider)
|
||||
.with_audit_logger(audit)
|
||||
.with_pipeline_config(PipelineConfig {
|
||||
budget_bytes: 4096,
|
||||
..PipelineConfig::default()
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(auth.pipeline().config().budget_bytes, 4096);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builder_minimal() {
|
||||
let vocab = create_test_vocab();
|
||||
let tfidf = Arc::new(GlobalTfIdfScorer::new(vocab));
|
||||
let semantic = Arc::new(SemanticScorer::new());
|
||||
let provider = Arc::new(MockPolicyProvider::new());
|
||||
|
||||
// Minimal config: scorers + policy provider (audit defaults to NoOp)
|
||||
let auth = AuthorizedPipelineBuilder::new()
|
||||
.with_scorers(tfidf, semantic)
|
||||
.with_policy_provider(provider)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Should work
|
||||
assert!(auth.pipeline().config().budget_bytes > 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Access Stats Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_access_stats_initialization() {
|
||||
let stats = AccessStats::new();
|
||||
|
||||
assert!(!stats.project_access_checked);
|
||||
assert!(!stats.project_access_allowed);
|
||||
assert_eq!(stats.chunks_before_filter, 0);
|
||||
assert_eq!(stats.chunks_after_filter, 0);
|
||||
assert_eq!(stats.chunks_denied, 0);
|
||||
assert!(stats.denied_reasons.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_access_stats_populated() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "test", public_policy())
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "user".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let graph = create_test_wiki_graph();
|
||||
let candidates = create_test_candidates();
|
||||
|
||||
let result = auth
|
||||
.execute_with_claims(&claims, "kubernetes", "test", &graph, candidates)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stats = &result.access_stats;
|
||||
|
||||
assert!(stats.project_access_checked);
|
||||
assert!(stats.project_access_allowed);
|
||||
// chunks_before_filter should be >= chunks_after_filter
|
||||
assert!(stats.chunks_before_filter >= stats.chunks_after_filter);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Group Membership Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_with_multiple_groups() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "devops", group_policy(vec!["devops"]))
|
||||
.with_policy("project", "engineering", group_policy(vec!["engineering"]))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// User in both groups
|
||||
let claims = OidcClaims {
|
||||
sub: "sre".to_string(),
|
||||
groups: vec!["devops".to_string(), "engineering".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
// Can access both projects
|
||||
assert!(auth.check_project_access(&claims, "devops").await.unwrap());
|
||||
assert!(auth.check_project_access(&claims, "engineering").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_permissions_combined() {
|
||||
let pipeline = create_test_pipeline();
|
||||
let provider = Arc::new(
|
||||
MockPolicyProvider::new()
|
||||
.with_policy("project", "special", permission_policy("special:access"))
|
||||
);
|
||||
let audit = Arc::new(NoOpAuditLogger);
|
||||
let auth = AuthorizedPipeline::new(pipeline, provider, audit, None);
|
||||
|
||||
// User with multiple permissions including required one
|
||||
let claims = OidcClaims {
|
||||
sub: "power-user".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec![
|
||||
"memory:read".to_string(),
|
||||
"memory:write".to_string(),
|
||||
"special:access".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
assert!(auth.check_project_access(&claims, "special").await.unwrap());
|
||||
}
|
||||
@@ -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