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,762 @@
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user