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:
2026-08-31 23:22:11 -07:00
parent cf409718b1
commit 56f8e8b391
11 changed files with 4478 additions and 10 deletions
+572
View File
@@ -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(&regular_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());
}
+450
View File
@@ -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);
}