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