Files
poimen-memory/crates/mem-cli/src/authorized_pipeline.rs
T
rock 2448e5ebe2 feat(rbac): hierarchical access control with fine-grained scopes
Implements comprehensive RBAC system:

Core Types (types.rs):
- Role: named set of AccessRules
- AccessRule: (resources, verbs, scope) tuple
- AccessScope: project/visibility/owner/group constraints
- ResourceMeta: document metadata for access checks
- Verb: read/write/delete/query
- Visibility: public/private per document

Role Provider (role_provider.rs):
- RoleProvider trait for pluggable backends
- YamlRoleProvider: load from YAML files
- InMemoryRoleProvider: for testing
- CompositeRoleProvider: layered lookup
- Built-in roles: admin, portfolio-agent, authenticated-user

Scope Checker (scope_checker.rs):
- ScopeChecker trait + composite pattern
- ProjectScopeChecker: allowed projects list
- VisibilityScopeChecker: public/private matching
- OwnerScopeChecker: self/any/specific user
- GroupScopeChecker: required group membership

Access Guard (access_guard.rs):
- Unified API for HTTP + retrieval layers
- check_http_capability(): memory:read/write checks
- filter_resources(): document-level filtering
- Audit logging for all decisions

Tests: 77 unit + 25 integration, all passing

Migration note: AuthorizedPipeline retained for compatibility,
will be replaced by AccessGuard integration in next phase.
2026-08-31 23:22:11 -07:00

763 lines
24 KiB
Rust

/// Authorized Pipeline: RBAC-protected retrieval
///
/// Wraps FullPipeline with JWT authentication and access control:
/// 1. Validate JWT token and extract claims
/// 2. Check project-level access before retrieval
/// 3. Filter results by document/skill access policies
/// 4. Audit log all access decisions
///
/// Access flow:
/// ```text
/// JWT Token → validate → OidcClaims
/// ↓
/// check_project_access(claims, project)
/// ↓ (if allowed)
/// FullPipeline.execute()
/// ↓
/// filter_results_by_access(claims, chunks)
/// ↓
/// AuthorizedResult { chunks, access_stats }
/// ```
use anyhow::{anyhow, Result};
use std::sync::Arc;
use mem_core::{GlobalTfIdfScorer, SemanticScorer};
use mem_ingest::wiki_link::WikiLinkGraph;
use crate::full_pipeline::{FullPipeline, PipelineConfig, PipelineResult, EnrichedChunk, PipelineMetrics};
use crate::rbac::{
AccessPolicy, PolicyProvider, AccessDecisionEngine, OidcClaims,
LegacyAccessDecision as AccessDecision,
LegacyAuditLogger as AuditLogger,
LegacyNoOpAuditLogger as NoOpAuditLogger,
};
use crate::jwt_validator::{JwtValidator, JwtClaims};
/// Access statistics for audit/metrics
#[derive(Debug, Clone)]
pub struct AccessStats {
pub project_access_checked: bool,
pub project_access_allowed: bool,
pub chunks_before_filter: usize,
pub chunks_after_filter: usize,
pub chunks_denied: usize,
pub denied_reasons: Vec<(String, String)>, // (chunk_id, reason)
}
impl AccessStats {
pub fn new() -> Self {
Self {
project_access_checked: false,
project_access_allowed: false,
chunks_before_filter: 0,
chunks_after_filter: 0,
chunks_denied: 0,
denied_reasons: Vec::new(),
}
}
}
/// Result with access control metadata
#[derive(Debug, Clone)]
pub struct AuthorizedResult {
pub result: PipelineResult,
pub access_stats: AccessStats,
pub user_id: String,
}
/// Authorized Pipeline: RBAC-protected retrieval
pub struct AuthorizedPipeline {
pipeline: FullPipeline,
access_engine: AccessDecisionEngine,
policy_provider: Arc<dyn PolicyProvider>,
jwt_validator: Option<Arc<JwtValidator>>,
}
impl AuthorizedPipeline {
pub fn new(
pipeline: FullPipeline,
policy_provider: Arc<dyn PolicyProvider>,
audit_logger: Arc<dyn AuditLogger>,
jwt_validator: Option<Arc<JwtValidator>>,
) -> Self {
let access_engine = AccessDecisionEngine::new(
policy_provider.clone(),
audit_logger,
);
Self {
pipeline,
access_engine,
policy_provider,
jwt_validator,
}
}
/// Validate JWT and extract OIDC claims
pub async fn validate_token(&self, token: &str) -> Result<OidcClaims> {
let validator = self.jwt_validator
.as_ref()
.ok_or_else(|| anyhow!("JWT validator not configured"))?;
let jwt_claims = validator.validate_token(token).await?;
Ok(OidcClaims {
sub: jwt_claims.sub,
groups: jwt_claims.groups.unwrap_or_default(),
roles: vec![], // Authentik may include roles differently
permissions: jwt_claims.permissions.unwrap_or_default(),
})
}
/// Check if user can access a project
pub async fn check_project_access(
&self,
claims: &OidcClaims,
project: &str,
) -> Result<bool> {
self.access_engine
.check_access(claims, "project", project)
.await
}
/// Check if user can access a specific document/skill
pub async fn check_document_access(
&self,
claims: &OidcClaims,
doc_id: &str,
) -> Result<bool> {
// Extract resource type from doc_id
let (resource_type, resource_name) = self.parse_doc_id(doc_id);
// Try to get policy, default to project policy if not found
match self.policy_provider.get_policy(&resource_type, &resource_name).await {
Ok(_) => {
self.access_engine
.check_access(claims, &resource_type, &resource_name)
.await
}
Err(_) => {
// No specific policy, allow if project access was granted
Ok(true)
}
}
}
/// Parse doc_id to determine resource type
fn parse_doc_id(&self, doc_id: &str) -> (String, String) {
if doc_id.contains("SKILL-") || doc_id.contains("/skills/") {
// Extract skill name
let skill_name = doc_id
.split('/')
.find(|s| s.starts_with("SKILL-"))
.unwrap_or(doc_id)
.to_string();
("skill".to_string(), skill_name)
} else {
// Regular document, use project-level access
("document".to_string(), doc_id.to_string())
}
}
/// Filter chunks by access policies
pub async fn filter_by_access(
&self,
claims: &OidcClaims,
chunks: Vec<EnrichedChunk>,
) -> (Vec<EnrichedChunk>, Vec<(String, String)>) {
let mut allowed = Vec::new();
let mut denied = Vec::new();
for chunk in chunks {
match self.check_document_access(claims, &chunk.id).await {
Ok(true) => allowed.push(chunk),
Ok(false) => denied.push((chunk.id.clone(), "access_denied".to_string())),
Err(e) => denied.push((chunk.id.clone(), format!("error: {}", e))),
}
}
(allowed, denied)
}
/// Execute with JWT token authentication
pub async fn execute_with_token(
&self,
token: &str,
query: &str,
project: &str,
wiki_graph: &WikiLinkGraph,
candidates: Vec<(String, String)>,
) -> Result<AuthorizedResult> {
// Step 1: Validate JWT
let claims = self.validate_token(token).await?;
// Step 2: Execute with claims
self.execute_with_claims(&claims, query, project, wiki_graph, candidates).await
}
/// Execute with pre-validated claims (for internal use or testing)
pub async fn execute_with_claims(
&self,
claims: &OidcClaims,
query: &str,
project: &str,
wiki_graph: &WikiLinkGraph,
candidates: Vec<(String, String)>,
) -> Result<AuthorizedResult> {
let mut access_stats = AccessStats::new();
access_stats.project_access_checked = true;
// Step 1: Check project access
let project_allowed = self.check_project_access(claims, project).await?;
access_stats.project_access_allowed = project_allowed;
if !project_allowed {
return Err(anyhow!(
"Access denied to project '{}' for user '{}'",
project,
claims.sub
));
}
// Step 2: Execute pipeline
let result = self.pipeline
.execute_with_wiki(query, wiki_graph, candidates)
.await?;
access_stats.chunks_before_filter = result.chunks.len();
// Step 3: Filter by document access
let (allowed_chunks, denied) = self.filter_by_access(claims, result.chunks).await;
access_stats.chunks_after_filter = allowed_chunks.len();
access_stats.chunks_denied = denied.len();
access_stats.denied_reasons = denied;
// Step 4: Build authorized result
let authorized_result = PipelineResult {
query: result.query,
query_intent: result.query_intent,
chunks: allowed_chunks,
metrics: result.metrics,
};
Ok(AuthorizedResult {
result: authorized_result,
access_stats,
user_id: claims.sub.clone(),
})
}
/// Execute direct (no wiki) with token
pub async fn execute_direct_with_token(
&self,
token: &str,
query: &str,
project: &str,
candidates: Vec<(String, String)>,
) -> Result<AuthorizedResult> {
let claims = self.validate_token(token).await?;
self.execute_direct_with_claims(&claims, query, project, candidates).await
}
/// Execute direct with claims
pub async fn execute_direct_with_claims(
&self,
claims: &OidcClaims,
query: &str,
project: &str,
candidates: Vec<(String, String)>,
) -> Result<AuthorizedResult> {
let mut access_stats = AccessStats::new();
access_stats.project_access_checked = true;
// Check project access
let project_allowed = self.check_project_access(claims, project).await?;
access_stats.project_access_allowed = project_allowed;
if !project_allowed {
return Err(anyhow!(
"Access denied to project '{}' for user '{}'",
project,
claims.sub
));
}
// Execute pipeline
let result = self.pipeline.execute_direct(query, candidates).await?;
access_stats.chunks_before_filter = result.chunks.len();
// Filter by access
let (allowed_chunks, denied) = self.filter_by_access(claims, result.chunks).await;
access_stats.chunks_after_filter = allowed_chunks.len();
access_stats.chunks_denied = denied.len();
access_stats.denied_reasons = denied;
let authorized_result = PipelineResult {
query: result.query,
query_intent: result.query_intent,
chunks: allowed_chunks,
metrics: result.metrics,
};
Ok(AuthorizedResult {
result: authorized_result,
access_stats,
user_id: claims.sub.clone(),
})
}
pub fn pipeline(&self) -> &FullPipeline {
&self.pipeline
}
}
/// Builder for AuthorizedPipeline
pub struct AuthorizedPipelineBuilder {
tfidf_scorer: Option<Arc<GlobalTfIdfScorer>>,
semantic_scorer: Option<Arc<SemanticScorer>>,
policy_provider: Option<Arc<dyn PolicyProvider>>,
audit_logger: Option<Arc<dyn AuditLogger>>,
jwt_validator: Option<Arc<JwtValidator>>,
pipeline_config: PipelineConfig,
}
impl AuthorizedPipelineBuilder {
pub fn new() -> Self {
Self {
tfidf_scorer: None,
semantic_scorer: None,
policy_provider: None,
audit_logger: None,
jwt_validator: None,
pipeline_config: PipelineConfig::default(),
}
}
pub fn with_scorers(
mut self,
tfidf: Arc<GlobalTfIdfScorer>,
semantic: Arc<SemanticScorer>,
) -> Self {
self.tfidf_scorer = Some(tfidf);
self.semantic_scorer = Some(semantic);
self
}
pub fn with_policy_provider(mut self, provider: Arc<dyn PolicyProvider>) -> Self {
self.policy_provider = Some(provider);
self
}
pub fn with_audit_logger(mut self, logger: Arc<dyn AuditLogger>) -> Self {
self.audit_logger = Some(logger);
self
}
pub fn with_jwt_validator(mut self, validator: Arc<JwtValidator>) -> Self {
self.jwt_validator = Some(validator);
self
}
pub fn with_pipeline_config(mut self, config: PipelineConfig) -> Self {
self.pipeline_config = config;
self
}
pub fn build(self) -> Result<AuthorizedPipeline> {
let tfidf = self.tfidf_scorer
.ok_or_else(|| anyhow!("TF-IDF scorer required"))?;
let semantic = self.semantic_scorer
.ok_or_else(|| anyhow!("Semantic scorer required"))?;
let policy_provider = self.policy_provider
.ok_or_else(|| anyhow!("PolicyProvider required"))?;
let audit_logger = self.audit_logger
.unwrap_or_else(|| Arc::new(NoOpAuditLogger));
let pipeline = FullPipeline::new(tfidf, semantic, self.pipeline_config);
Ok(AuthorizedPipeline::new(
pipeline,
policy_provider,
audit_logger,
self.jwt_validator,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use crate::rbac::MockPolicyProvider;
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);
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_public_policy() -> AccessPolicy {
AccessPolicy {
access_level: "public".to_string(),
owner_group: "".to_string(),
allowed_groups: vec![],
required_role: None,
required_permission: None,
}
}
fn create_group_policy(groups: Vec<&str>) -> AccessPolicy {
AccessPolicy {
access_level: "group".to_string(),
owner_group: "".to_string(),
allowed_groups: groups.into_iter().map(|s| s.to_string()).collect(),
required_role: None,
required_permission: None,
}
}
fn create_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 create_test_wiki_graph() -> WikiLinkGraph {
let mut graph = WikiLinkGraph::new("test");
graph.add_link("index.md", "docs/guide.md");
graph
}
#[test]
fn test_access_stats_new() {
let stats = AccessStats::new();
assert!(!stats.project_access_checked);
assert!(!stats.project_access_allowed);
assert_eq!(stats.chunks_before_filter, 0);
}
#[test]
fn test_parse_doc_id_skill() {
let vocab = create_test_vocab();
let tfidf = Arc::new(GlobalTfIdfScorer::new(vocab));
let semantic = Arc::new(SemanticScorer::new());
let pipeline = FullPipeline::new(tfidf, semantic, PipelineConfig::default());
let provider = Arc::new(MockPolicyProvider::new());
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let (rtype, rname) = auth_pipeline.parse_doc_id("shared/skills/SKILL-kubernetes-debug/SKILL.md");
assert_eq!(rtype, "skill");
assert!(rname.contains("SKILL-"));
}
#[test]
fn test_parse_doc_id_document() {
let vocab = create_test_vocab();
let tfidf = Arc::new(GlobalTfIdfScorer::new(vocab));
let semantic = Arc::new(SemanticScorer::new());
let pipeline = FullPipeline::new(tfidf, semantic, PipelineConfig::default());
let provider = Arc::new(MockPolicyProvider::new());
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let (rtype, _) = auth_pipeline.parse_doc_id("docs/guide.md");
assert_eq!(rtype, "document");
}
#[tokio::test]
async fn test_check_project_access_public() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "poimen", create_public_policy())
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "anyone".to_string(),
groups: vec![],
roles: vec![],
permissions: vec![],
};
let allowed = auth_pipeline.check_project_access(&claims, "poimen").await.unwrap();
assert!(allowed);
}
#[tokio::test]
async fn test_check_project_access_group_allowed() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "poimen", create_group_policy(vec!["platform-team"]))
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "charlie".to_string(),
groups: vec!["platform-team".to_string()],
roles: vec![],
permissions: vec![],
};
let allowed = auth_pipeline.check_project_access(&claims, "poimen").await.unwrap();
assert!(allowed);
}
#[tokio::test]
async fn test_check_project_access_group_denied() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "poimen", create_group_policy(vec!["platform-team"]))
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "alice".to_string(),
groups: vec!["data-team".to_string()],
roles: vec![],
permissions: vec![],
};
let allowed = auth_pipeline.check_project_access(&claims, "poimen").await.unwrap();
assert!(!allowed);
}
#[tokio::test]
async fn test_check_project_access_private() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "secret", create_private_policy("ml-team"))
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
// Owner has access
let owner_claims = OidcClaims {
sub: "bob".to_string(),
groups: vec!["ml-team".to_string()],
roles: vec![],
permissions: vec![],
};
assert!(auth_pipeline.check_project_access(&owner_claims, "secret").await.unwrap());
// Non-owner denied
let other_claims = OidcClaims {
sub: "charlie".to_string(),
groups: vec!["platform-team".to_string()],
roles: vec![],
permissions: vec![],
};
assert!(!auth_pipeline.check_project_access(&other_claims, "secret").await.unwrap());
}
#[tokio::test]
async fn test_execute_with_claims_allowed() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "poimen", create_public_policy())
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "charlie".to_string(),
groups: vec!["platform-team".to_string()],
roles: vec![],
permissions: vec![],
};
let graph = create_test_wiki_graph();
let candidates = vec![
("index.md".to_string(), "kubernetes guide".to_string()),
];
let result = auth_pipeline
.execute_with_claims(&claims, "kubernetes", "poimen", &graph, candidates)
.await
.unwrap();
assert!(result.access_stats.project_access_allowed);
assert_eq!(result.user_id, "charlie");
}
#[tokio::test]
async fn test_execute_with_claims_denied() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "secret", create_private_policy("ml-team"))
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "charlie".to_string(),
groups: vec!["platform-team".to_string()], // Not ml-team
roles: vec![],
permissions: vec![],
};
let graph = create_test_wiki_graph();
let candidates = vec![
("index.md".to_string(), "secret content".to_string()),
];
let result = auth_pipeline
.execute_with_claims(&claims, "query", "secret", &graph, candidates)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Access denied"));
}
#[tokio::test]
async fn test_filter_by_access_skill() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "poimen", create_public_policy())
.with_policy("skill", "SKILL-private", create_private_policy("ml-team"))
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "charlie".to_string(),
groups: vec!["platform-team".to_string()],
roles: vec![],
permissions: vec![],
};
let chunks = vec![
EnrichedChunk {
id: "docs/public.md".to_string(),
text: "public content".to_string(),
tfidf_score: 0.5,
semantic_score: 0.5,
rrf_score: 0.5,
pre_boost_score: 0.5,
final_score: 0.5,
category: crate::chunk_metadata::ChunkCategory::Concept,
heading: None,
key_terms: vec![],
metadata_boost: 0.0,
query_intent_match: false,
wiki_distance: None,
cache_slot: 0,
cache_priority: 0.5,
},
EnrichedChunk {
id: "shared/skills/SKILL-private/SKILL.md".to_string(),
text: "private skill".to_string(),
tfidf_score: 0.8,
semantic_score: 0.8,
rrf_score: 0.8,
pre_boost_score: 0.8,
final_score: 0.8,
category: crate::chunk_metadata::ChunkCategory::Tool,
heading: None,
key_terms: vec![],
metadata_boost: 0.0,
query_intent_match: false,
wiki_distance: None,
cache_slot: 0,
cache_priority: 0.8,
},
];
let (allowed, denied) = auth_pipeline.filter_by_access(&claims, chunks).await;
// Public doc allowed, private skill denied
assert_eq!(allowed.len(), 1);
assert_eq!(denied.len(), 1);
assert_eq!(allowed[0].id, "docs/public.md");
assert!(denied[0].0.contains("SKILL-private"));
}
#[tokio::test]
async fn test_builder() {
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 auth_pipeline = AuthorizedPipelineBuilder::new()
.with_scorers(tfidf, semantic)
.with_policy_provider(provider)
.build()
.unwrap();
// Should build successfully
assert!(auth_pipeline.jwt_validator.is_none());
}
#[tokio::test]
async fn test_builder_missing_provider() {
let vocab = create_test_vocab();
let tfidf = Arc::new(GlobalTfIdfScorer::new(vocab));
let semantic = Arc::new(SemanticScorer::new());
let result = AuthorizedPipelineBuilder::new()
.with_scorers(tfidf, semantic)
// Missing policy_provider
.build();
assert!(result.is_err());
}
#[tokio::test]
async fn test_execute_direct_with_claims() {
let pipeline = create_test_pipeline();
let provider = Arc::new(
MockPolicyProvider::new()
.with_policy("project", "test", create_public_policy())
);
let audit = Arc::new(NoOpAuditLogger);
let auth_pipeline = AuthorizedPipeline::new(pipeline, provider, audit, None);
let claims = OidcClaims {
sub: "user".to_string(),
groups: vec![],
roles: vec![],
permissions: vec![],
};
let candidates = vec![
("doc.md".to_string(), "content".to_string()),
];
let result = auth_pipeline
.execute_direct_with_claims(&claims, "query", "test", candidates)
.await
.unwrap();
assert!(result.access_stats.project_access_allowed);
}
}