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
+185
View File
@@ -0,0 +1,185 @@
/// Fluent builders for test data construction (DRY principle)
/// Builder for OidcClaims (OIDC token claims from Authentik)
pub struct OidcClaimsBuilder {
sub: String,
groups: Vec<String>,
roles: Vec<String>,
permissions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OidcClaims {
pub sub: String,
pub groups: Vec<String>,
pub roles: Vec<String>,
pub permissions: Vec<String>,
}
impl OidcClaimsBuilder {
pub fn new(sub: &str) -> Self {
Self {
sub: sub.to_string(),
groups: vec![],
roles: vec!["viewer".to_string()],
permissions: vec![],
}
}
pub fn group(mut self, group: &str) -> Self {
self.groups.push(group.to_string());
self
}
pub fn groups(mut self, groups: Vec<&str>) -> Self {
self.groups = groups.iter().map(|s| s.to_string()).collect();
self
}
pub fn role(mut self, role: &str) -> Self {
self.roles.push(role.to_string());
self
}
pub fn permission(mut self, perm: &str) -> Self {
self.permissions.push(perm.to_string());
self
}
pub fn permissions(mut self, perms: Vec<&str>) -> Self {
self.permissions = perms.iter().map(|s| s.to_string()).collect();
self
}
pub fn build(self) -> OidcClaims {
OidcClaims {
sub: self.sub,
groups: self.groups,
roles: self.roles,
permissions: self.permissions,
}
}
}
/// Builder for AccessPolicy (RBAC policy from Vault)
pub struct AccessPolicyBuilder {
access_level: String,
owner_group: String,
allowed_groups: Vec<String>,
required_role: Option<String>,
required_permission: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AccessPolicy {
pub access_level: String,
pub owner_group: String,
pub allowed_groups: Vec<String>,
pub required_role: Option<String>,
pub required_permission: Option<String>,
}
impl AccessPolicyBuilder {
pub fn public() -> Self {
Self {
access_level: "public".to_string(),
owner_group: Default::default(),
allowed_groups: Default::default(),
required_role: None,
required_permission: None,
}
}
pub fn private(mut self, owner: &str) -> Self {
self.access_level = "private".to_string();
self.owner_group = owner.to_string();
self
}
pub fn group(mut self, groups: Vec<&str>) -> Self {
self.access_level = "group".to_string();
self.allowed_groups = groups.iter().map(|s| s.to_string()).collect();
self
}
pub fn require_role(mut self, role: &str) -> Self {
self.required_role = Some(role.to_string());
self
}
pub fn require_permission(mut self, perm: &str) -> Self {
self.required_permission = Some(perm.to_string());
self
}
pub fn build(self) -> AccessPolicy {
AccessPolicy {
access_level: self.access_level,
owner_group: self.owner_group,
allowed_groups: self.allowed_groups,
required_role: self.required_role,
required_permission: self.required_permission,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_oidc_claims_builder_basic() {
let claims = OidcClaimsBuilder::new("charlie")
.group("platform-team")
.permission("memory:read")
.build();
assert_eq!(claims.sub, "charlie");
assert_eq!(claims.groups, vec!["platform-team"]);
assert!(claims.permissions.contains(&"memory:read".to_string()));
}
#[test]
fn test_oidc_claims_builder_multiple_groups() {
let claims = OidcClaimsBuilder::new("alice")
.groups(vec!["platform-team", "devops-team"])
.role("editor")
.build();
assert_eq!(claims.groups.len(), 2);
assert!(claims.groups.contains(&"platform-team".to_string()));
assert!(claims.groups.contains(&"devops-team".to_string()));
assert!(claims.roles.contains(&"editor".to_string()));
}
#[test]
fn test_access_policy_public() {
let policy = AccessPolicyBuilder::public().build();
assert_eq!(policy.access_level, "public");
assert!(policy.owner_group.is_empty());
assert!(policy.allowed_groups.is_empty());
}
#[test]
fn test_access_policy_private() {
let policy = AccessPolicyBuilder::public()
.private("ml-team")
.build();
assert_eq!(policy.access_level, "private");
assert_eq!(policy.owner_group, "ml-team");
}
#[test]
fn test_access_policy_group() {
let policy = AccessPolicyBuilder::public()
.group(vec!["platform-team", "devops-team"])
.require_role("viewer")
.build();
assert_eq!(policy.access_level, "group");
assert_eq!(policy.allowed_groups.len(), 2);
assert_eq!(policy.required_role, Some("viewer".to_string()));
}
}
+150
View File
@@ -0,0 +1,150 @@
/// Mock implementations for testing (pluggable traits)
use std::sync::Arc;
use std::sync::Mutex;
use super::builders::{AccessPolicy, OidcClaims};
use anyhow::Result;
/// Mock PolicyProvider for testing (no Vault dependency)
pub struct MockPolicyProvider {
policies: Arc<Mutex<std::collections::HashMap<String, AccessPolicy>>>,
}
impl MockPolicyProvider {
pub fn new() -> Self {
Self {
policies: Arc::new(Mutex::new(std::collections::HashMap::new())),
}
}
pub fn with_policy(
mut 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
}
pub 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))
}
}
/// Mock AuditLogger for testing (records decisions, no I/O)
#[derive(Debug, Clone)]
pub struct AccessDecision {
pub user_id: String,
pub resource_type: String,
pub resource_name: String,
pub decision: String,
pub reason: String,
}
pub struct MockAuditLogger {
decisions: Arc<Mutex<Vec<AccessDecision>>>,
}
impl MockAuditLogger {
pub fn new() -> Self {
Self {
decisions: Arc::new(Mutex::new(Vec::new())),
}
}
pub async fn log_decision(&self, decision: AccessDecision) -> Result<()> {
self.decisions.lock().unwrap().push(decision);
Ok(())
}
pub fn decisions(&self) -> Vec<AccessDecision> {
self.decisions.lock().unwrap().clone()
}
pub fn last_decision(&self) -> Option<AccessDecision> {
self.decisions.lock().unwrap().last().cloned()
}
pub fn clear(&self) {
self.decisions.lock().unwrap().clear();
}
}
/// Mock DocumentScorer for testing (returns constant score)
pub struct ConstantScorer {
score: f32,
}
impl ConstantScorer {
pub fn new(score: f32) -> Self {
Self { score }
}
pub async fn score(&self, _query: &str, _doc_id: &str) -> Result<f32> {
Ok(self.score)
}
pub fn name(&self) -> &str {
"constant-mock"
}
}
#[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, policy);
}
#[tokio::test]
async fn test_mock_audit_logger() {
let logger = MockAuditLogger::new();
logger
.log_decision(AccessDecision {
user_id: "charlie".to_string(),
resource_type: "project".to_string(),
resource_name: "poimen".to_string(),
decision: "allow".to_string(),
reason: "in_allowed_group".to_string(),
})
.await
.unwrap();
let decisions = logger.decisions();
assert_eq!(decisions.len(), 1);
assert_eq!(decisions[0].user_id, "charlie");
}
#[test]
fn test_constant_scorer() {
let scorer = ConstantScorer::new(0.75);
assert_eq!(scorer.name(), "constant-mock");
}
}
+3
View File
@@ -0,0 +1,3 @@
/// Reusable test fixtures and builders for all test suites
pub mod builders;
pub mod mocks;