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:
2026-08-30 20:40:43 -07:00
parent 2850907167
commit 985f65d1f4
14 changed files with 1423 additions and 2 deletions
+315
View File
@@ -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);
}
}