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:
Vendored
+185
@@ -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()));
|
||||
}
|
||||
}
|
||||
Vendored
+150
@@ -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");
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// Reusable test fixtures and builders for all test suites
|
||||
pub mod builders;
|
||||
pub mod mocks;
|
||||
Reference in New Issue
Block a user