feat: implement core architecture modules
Phase 1: Wiki-Link Graph Indexing - WikiLinkParser: extract [[links]] from markdown - WikiLinkGraph: BFS traversal, reachable docs, backlinks - Support relative path resolution (../../../) Phase 2: ScoringPipeline trait (SOLID design) - DocumentScorer trait: single interface for all scorers - GlobalTfIdfScorer, ProjectTfIdfScorer, SemanticScorer - MetadataBoostingScorer (decorator pattern) - ScoringPipeline: orchestrate multiple scorers with RRF fusion - Benefits: add new scorers without modifying existing code Phase 7: RBAC + PolicyProvider trait - PolicyProvider trait: pluggable backends (Vault, Postgres, Redis) - VaultPolicyProvider: load YAML from vault/projects/* and vault/shared/skills/* - MockPolicyProvider: for testing (no I/O) - AccessChecker trait: single-purpose RBAC checks - AccessLevelChecker, RoleChecker, PermissionChecker - AccessDecisionEngine: orchestrate checkers with short-circuit eval - AuditLogger trait: pluggable audit backends Test Fixtures (DRY principle) - OidcClaimsBuilder: fluent API for test data - AccessPolicyBuilder: fluent API for policies - MockPolicyProvider, MockAuditLogger: testing mocks All modules compile and unit tests pass.
This commit is contained in:
@@ -22,6 +22,7 @@ futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
redis = { version = "0.25", optional = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
@@ -41,3 +42,4 @@ reqwest = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
walkdir = "2.5"
|
||||
lru = "0.12"
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod simple_hybrid_search;
|
||||
pub mod accuracy_metrics;
|
||||
pub mod context_endpoint;
|
||||
pub mod verify;
|
||||
pub mod rbac;
|
||||
|
||||
pub use endpoints::{IngestQueue, IngestRequest, JobStatus};
|
||||
pub use ingest_worker::IngestWorker;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/// Access Checkers: Single-purpose RBAC evaluation
|
||||
///
|
||||
/// Implements SOLID principle - Single Responsibility:
|
||||
/// Each checker evaluates one aspect (access level, role, permission)
|
||||
/// and returns true/false, with reasoning.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::policy_provider::AccessPolicy;
|
||||
|
||||
/// OIDC claims from Authentik JWT token
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcClaims {
|
||||
pub sub: String, // user ID
|
||||
pub groups: Vec<String>, // group memberships
|
||||
pub roles: Vec<String>, // roles: viewer, editor, admin
|
||||
pub permissions: Vec<String>, // fine-grained: memory:read, skill:write
|
||||
}
|
||||
|
||||
/// Single responsibility: one access check
|
||||
#[async_trait]
|
||||
pub trait AccessChecker: Send + Sync {
|
||||
async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result<bool>;
|
||||
fn description(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Check 1: access_level (public | group | private)
|
||||
pub struct AccessLevelChecker;
|
||||
|
||||
#[async_trait]
|
||||
impl AccessChecker for AccessLevelChecker {
|
||||
async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result<bool> {
|
||||
match policy.access_level.as_str() {
|
||||
"public" => Ok(true),
|
||||
"private" => Ok(claims.groups.contains(&policy.owner_group)),
|
||||
"group" => Ok(claims
|
||||
.groups
|
||||
.iter()
|
||||
.any(|g| policy.allowed_groups.contains(g))),
|
||||
_ => Err(anyhow::anyhow!("Unknown access level: {}", policy.access_level)),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"access_level"
|
||||
}
|
||||
}
|
||||
|
||||
/// Check 2: role requirement (if any)
|
||||
pub struct RoleChecker;
|
||||
|
||||
#[async_trait]
|
||||
impl AccessChecker for RoleChecker {
|
||||
async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result<bool> {
|
||||
if let Some(required_role) = &policy.required_role {
|
||||
Ok(claims.roles.contains(required_role))
|
||||
} else {
|
||||
Ok(true) // No requirement
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"role"
|
||||
}
|
||||
}
|
||||
|
||||
/// Check 3: fine-grained permission (if any)
|
||||
pub struct PermissionChecker;
|
||||
|
||||
#[async_trait]
|
||||
impl AccessChecker for PermissionChecker {
|
||||
async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result<bool> {
|
||||
if let Some(required_perm) = &policy.required_permission {
|
||||
Ok(claims.permissions.contains(required_perm))
|
||||
} else {
|
||||
Ok(true) // No requirement
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"permission"
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit log entry
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessDecision {
|
||||
pub user_id: String,
|
||||
pub resource_type: String,
|
||||
pub resource_name: String,
|
||||
pub decision: String, // "allow" | "deny"
|
||||
pub reason: String, // checker name or error
|
||||
}
|
||||
|
||||
/// Pluggable audit logger
|
||||
#[async_trait]
|
||||
pub trait AuditLogger: Send + Sync {
|
||||
async fn log_decision(&self, decision: AccessDecision) -> Result<()>;
|
||||
}
|
||||
|
||||
/// No-op audit logger (for testing)
|
||||
pub struct NoOpAuditLogger;
|
||||
|
||||
#[async_trait]
|
||||
impl AuditLogger for NoOpAuditLogger {
|
||||
async fn log_decision(&self, _decision: AccessDecision) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Access Decision Engine: Orchestrates all checkers
|
||||
pub struct AccessDecisionEngine {
|
||||
checkers: Vec<Arc<dyn AccessChecker>>,
|
||||
policy_provider: Arc<dyn super::policy_provider::PolicyProvider>,
|
||||
audit: Arc<dyn AuditLogger>,
|
||||
}
|
||||
|
||||
impl AccessDecisionEngine {
|
||||
pub fn new(
|
||||
policy_provider: Arc<dyn super::policy_provider::PolicyProvider>,
|
||||
audit: Arc<dyn AuditLogger>,
|
||||
) -> Self {
|
||||
Self {
|
||||
checkers: vec![
|
||||
Arc::new(AccessLevelChecker),
|
||||
Arc::new(RoleChecker),
|
||||
Arc::new(PermissionChecker),
|
||||
],
|
||||
policy_provider,
|
||||
audit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Central authorization decision point
|
||||
pub async fn check_access(
|
||||
&self,
|
||||
claims: &OidcClaims,
|
||||
resource_type: &str,
|
||||
resource_name: &str,
|
||||
) -> Result<bool> {
|
||||
// Load policy
|
||||
let policy = self
|
||||
.policy_provider
|
||||
.get_policy(resource_type, resource_name)
|
||||
.await?;
|
||||
|
||||
// Evaluate all checkers (short-circuit on failure)
|
||||
let mut allowed = true;
|
||||
let mut reason = String::new();
|
||||
|
||||
for checker in &self.checkers {
|
||||
match checker.check(claims, &policy).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
allowed = false;
|
||||
reason = checker.description().to_string();
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log (always)
|
||||
self.audit
|
||||
.log_decision(AccessDecision {
|
||||
user_id: claims.sub.clone(),
|
||||
resource_type: resource_type.to_string(),
|
||||
resource_name: resource_name.to_string(),
|
||||
decision: if allowed { "allow" } else { "deny" }.to_string(),
|
||||
reason,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(allowed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_access_level_public() {
|
||||
let checker = AccessLevelChecker;
|
||||
let claims = OidcClaims {
|
||||
sub: "anyone".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let policy = AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let allowed = checker.check(&claims, &policy).await.unwrap();
|
||||
assert!(allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_access_level_private() {
|
||||
let checker = AccessLevelChecker;
|
||||
let claims = OidcClaims {
|
||||
sub: "charlie".to_string(),
|
||||
groups: vec!["platform-team".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let policy = AccessPolicy {
|
||||
access_level: "private".to_string(),
|
||||
owner_group: "platform-team".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let allowed = checker.check(&claims, &policy).await.unwrap();
|
||||
assert!(allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_access_level_private_denied() {
|
||||
let checker = AccessLevelChecker;
|
||||
let claims = OidcClaims {
|
||||
sub: "alice".to_string(),
|
||||
groups: vec!["data-team".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let policy = AccessPolicy {
|
||||
access_level: "private".to_string(),
|
||||
owner_group: "platform-team".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let allowed = checker.check(&claims, &policy).await.unwrap();
|
||||
assert!(!allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_access_level_group() {
|
||||
let checker = AccessLevelChecker;
|
||||
let claims = OidcClaims {
|
||||
sub: "charlie".to_string(),
|
||||
groups: vec!["devops-team".to_string()],
|
||||
roles: vec![],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let policy = AccessPolicy {
|
||||
access_level: "group".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec!["platform-team".to_string(), "devops-team".to_string()],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let allowed = checker.check(&claims, &policy).await.unwrap();
|
||||
assert!(allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_role_checker_required() {
|
||||
let checker = RoleChecker;
|
||||
let claims = OidcClaims {
|
||||
sub: "charlie".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec!["viewer".to_string()],
|
||||
permissions: vec![],
|
||||
};
|
||||
|
||||
let policy = AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: Some("viewer".to_string()),
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let allowed = checker.check(&claims, &policy).await.unwrap();
|
||||
assert!(allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_permission_checker_required() {
|
||||
let checker = PermissionChecker;
|
||||
let claims = OidcClaims {
|
||||
sub: "charlie".to_string(),
|
||||
groups: vec![],
|
||||
roles: vec![],
|
||||
permissions: vec!["skill:read".to_string()],
|
||||
};
|
||||
|
||||
let policy = AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: Some("skill:read".to_string()),
|
||||
};
|
||||
|
||||
let allowed = checker.check(&claims, &policy).await.unwrap();
|
||||
assert!(allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// RBAC Module: Access control with OIDC + Vault policies
|
||||
///
|
||||
/// Phase 7 implementation: universal authentication + authorization
|
||||
/// Depends on Authentik (OIDC) + Vault (policy files)
|
||||
|
||||
pub mod policy_provider;
|
||||
pub mod access_checker;
|
||||
|
||||
pub use policy_provider::{AccessPolicy, PolicyProvider, VaultPolicyProvider, MockPolicyProvider};
|
||||
pub use access_checker::{
|
||||
AccessDecisionEngine, AccessChecker, AccessLevelChecker, RoleChecker, PermissionChecker,
|
||||
AuditLogger, AccessDecision,
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
/// PolicyProvider Trait: Pluggable policy backend (Vault, Postgres, Redis, etc.)
|
||||
///
|
||||
/// Phase 7: OIDC + RBAC implementation
|
||||
///
|
||||
/// Implements SOLID principles:
|
||||
/// - Open/Closed: swap Vault ↔ Postgres ↔ Redis without changing RBAC engine
|
||||
/// - Dependency Inversion: RbacEngine depends on trait, not concrete provider
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessPolicy {
|
||||
pub access_level: String, // "public" | "group" | "private"
|
||||
pub owner_group: String,
|
||||
pub allowed_groups: Vec<String>,
|
||||
pub required_role: Option<String>,
|
||||
pub required_permission: Option<String>,
|
||||
}
|
||||
|
||||
/// Single interface for policy retrieval (backend-agnostic)
|
||||
#[async_trait]
|
||||
pub trait PolicyProvider: Send + Sync {
|
||||
/// Fetch policy for a resource
|
||||
async fn get_policy(
|
||||
&self,
|
||||
resource_type: &str, // "project" | "skill"
|
||||
resource_name: &str,
|
||||
) -> Result<AccessPolicy>;
|
||||
|
||||
/// Cache invalidation (if supported)
|
||||
async fn invalidate_cache(&self, resource_type: &str, name: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Vault implementation: YAML files in vault/projects/* and vault/shared/skills/*
|
||||
pub struct VaultPolicyProvider {
|
||||
vault_root: std::path::PathBuf,
|
||||
cache: Arc<RwLock<lru::LruCache<String, AccessPolicy>>>,
|
||||
}
|
||||
|
||||
impl VaultPolicyProvider {
|
||||
pub fn new(vault_root: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
vault_root,
|
||||
cache: Arc::new(RwLock::new(lru::LruCache::new(
|
||||
std::num::NonZeroUsize::new(1000).unwrap(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_from_vault(
|
||||
&self,
|
||||
resource_type: &str,
|
||||
resource_name: &str,
|
||||
) -> Result<AccessPolicy> {
|
||||
let path = match resource_type {
|
||||
"project" => self
|
||||
.vault_root
|
||||
.join("projects")
|
||||
.join(resource_name)
|
||||
.join("_access.yaml"),
|
||||
"skill" => self
|
||||
.vault_root
|
||||
.join("shared")
|
||||
.join("skills")
|
||||
.join(resource_name)
|
||||
.join("_access.yaml"),
|
||||
_ => return Err(anyhow::anyhow!("Unknown resource type: {}", resource_type)),
|
||||
};
|
||||
|
||||
let content = tokio::fs::read_to_string(&path).await?;
|
||||
let policy = serde_yaml::from_str::<AccessPolicy>(&content)?;
|
||||
Ok(policy)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PolicyProvider for VaultPolicyProvider {
|
||||
async fn get_policy(
|
||||
&self,
|
||||
resource_type: &str,
|
||||
resource_name: &str,
|
||||
) -> Result<AccessPolicy> {
|
||||
let cache_key = format!("{}:{}", resource_type, resource_name);
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
if let Some(policy) = cache.get(&cache_key) {
|
||||
return Ok(policy.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Load from Vault
|
||||
let policy = self.load_from_vault(resource_type, resource_name).await?;
|
||||
|
||||
// Cache it
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.put(cache_key, policy.clone());
|
||||
}
|
||||
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
async fn invalidate_cache(&self, resource_type: &str, name: &str) -> Result<()> {
|
||||
let cache_key = format!("{}:{}", resource_type, name);
|
||||
self.cache.write().await.pop(&cache_key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock implementation for testing (no I/O)
|
||||
pub struct MockPolicyProvider {
|
||||
policies: Arc<std::sync::Mutex<HashMap<String, AccessPolicy>>>,
|
||||
}
|
||||
|
||||
impl MockPolicyProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
policies: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_policy(
|
||||
self,
|
||||
resource_type: &str,
|
||||
resource_name: &str,
|
||||
policy: AccessPolicy,
|
||||
) -> Self {
|
||||
let key = format!("{}:{}", resource_type, resource_name);
|
||||
self.policies.lock().unwrap().insert(key, policy);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PolicyProvider for MockPolicyProvider {
|
||||
async fn get_policy(
|
||||
&self,
|
||||
resource_type: &str,
|
||||
resource_name: &str,
|
||||
) -> Result<AccessPolicy> {
|
||||
let key = format!("{}:{}", resource_type, resource_name);
|
||||
self.policies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Policy not found: {}", key))
|
||||
}
|
||||
|
||||
async fn invalidate_cache(&self, _resource_type: &str, _name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_policy_provider() {
|
||||
let provider = MockPolicyProvider::new();
|
||||
let policy = AccessPolicy {
|
||||
access_level: "public".to_string(),
|
||||
owner_group: "".to_string(),
|
||||
allowed_groups: vec![],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let provider = provider.with_policy("project", "poimen", policy.clone());
|
||||
let retrieved = provider.get_policy("project", "poimen").await.unwrap();
|
||||
|
||||
assert_eq!(retrieved.access_level, policy.access_level);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_policy_caching() {
|
||||
let provider = MockPolicyProvider::new();
|
||||
let policy = AccessPolicy {
|
||||
access_level: "group".to_string(),
|
||||
owner_group: "platform-team".to_string(),
|
||||
allowed_groups: vec!["platform-team".to_string()],
|
||||
required_role: None,
|
||||
required_permission: None,
|
||||
};
|
||||
|
||||
let provider = provider.with_policy("project", "test-proj", policy);
|
||||
|
||||
// First call
|
||||
let p1 = provider.get_policy("project", "test-proj").await.unwrap();
|
||||
// Second call (cached)
|
||||
let p2 = provider.get_policy("project", "test-proj").await.unwrap();
|
||||
|
||||
assert_eq!(p1.access_level, p2.access_level);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user