/// Role Provider: Load and cache role definitions /// /// Implementations: /// - YamlRoleProvider: Load from YAML files (dev/testing) /// - PostgresRoleProvider: Load from database (production) /// - InMemoryRoleProvider: For testing use anyhow::{anyhow, Result}; use async_trait::async_trait; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::RwLock; use tokio::fs; use super::types::Role; // ============================================================================ // Trait // ============================================================================ /// Provider for role definitions #[async_trait] pub trait RoleProvider: Send + Sync { /// Get a role by name async fn get_role(&self, name: &str) -> Result>; /// Get all roles for a list of role names async fn get_roles(&self, names: &[String]) -> Result> { let mut roles = Vec::new(); for name in names { if let Some(role) = self.get_role(name).await? { roles.push(role); } } Ok(roles) } /// List all available role names async fn list_roles(&self) -> Result>; /// Invalidate cache for a role (if caching is used) async fn invalidate(&self, name: &str) -> Result<()>; /// Invalidate all cached roles async fn invalidate_all(&self) -> Result<()>; } // ============================================================================ // YAML Provider // ============================================================================ /// Load roles from YAML files in a directory /// /// Directory structure: /// ```text /// roles/ /// ├── admin.yaml /// ├── portfolio-agent.yaml /// └── authenticated-user.yaml /// ``` pub struct YamlRoleProvider { roles_dir: PathBuf, cache: RwLock>, } impl YamlRoleProvider { pub fn new(roles_dir: impl AsRef) -> Self { Self { roles_dir: roles_dir.as_ref().to_path_buf(), cache: RwLock::new(HashMap::new()), } } fn role_path(&self, name: &str) -> PathBuf { self.roles_dir.join(format!("{}.yaml", name)) } async fn load_role(&self, name: &str) -> Result> { let path = self.role_path(name); if !path.exists() { // Try .yml extension let alt_path = self.roles_dir.join(format!("{}.yml", name)); if !alt_path.exists() { return Ok(None); } return self.load_from_path(&alt_path).await; } self.load_from_path(&path).await } async fn load_from_path(&self, path: &Path) -> Result> { let content = fs::read_to_string(path).await?; let role: Role = serde_yaml::from_str(&content) .map_err(|e| anyhow!("Failed to parse role from {:?}: {}", path, e))?; Ok(Some(role)) } } #[async_trait] impl RoleProvider for YamlRoleProvider { async fn get_role(&self, name: &str) -> Result> { // Check cache first { let cache = self.cache.read().unwrap(); if let Some(role) = cache.get(name) { return Ok(Some(role.clone())); } } // Load from file if let Some(role) = self.load_role(name).await? { let mut cache = self.cache.write().unwrap(); cache.insert(name.to_string(), role.clone()); return Ok(Some(role)); } Ok(None) } async fn list_roles(&self) -> Result> { let mut roles = Vec::new(); if !self.roles_dir.exists() { return Ok(roles); } let mut entries = fs::read_dir(&self.roles_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if let Some(ext) = path.extension() { if ext == "yaml" || ext == "yml" { if let Some(stem) = path.file_stem() { roles.push(stem.to_string_lossy().to_string()); } } } } Ok(roles) } async fn invalidate(&self, name: &str) -> Result<()> { let mut cache = self.cache.write().unwrap(); cache.remove(name); Ok(()) } async fn invalidate_all(&self) -> Result<()> { let mut cache = self.cache.write().unwrap(); cache.clear(); Ok(()) } } // ============================================================================ // In-Memory Provider (Testing) // ============================================================================ /// In-memory role provider for testing pub struct InMemoryRoleProvider { roles: RwLock>, } impl InMemoryRoleProvider { pub fn new() -> Self { Self { roles: RwLock::new(HashMap::new()), } } /// Add a role pub fn add_role(self, role: Role) -> Self { self.roles.write().unwrap().insert(role.name.clone(), role); self } /// Add multiple roles pub fn with_roles(self, roles: Vec) -> Self { let mut cache = self.roles.write().unwrap(); for role in roles { cache.insert(role.name.clone(), role); } drop(cache); self } } impl Default for InMemoryRoleProvider { fn default() -> Self { Self::new() } } #[async_trait] impl RoleProvider for InMemoryRoleProvider { async fn get_role(&self, name: &str) -> Result> { let roles = self.roles.read().unwrap(); Ok(roles.get(name).cloned()) } async fn list_roles(&self) -> Result> { let roles = self.roles.read().unwrap(); Ok(roles.keys().cloned().collect()) } async fn invalidate(&self, name: &str) -> Result<()> { self.roles.write().unwrap().remove(name); Ok(()) } async fn invalidate_all(&self) -> Result<()> { self.roles.write().unwrap().clear(); Ok(()) } } // ============================================================================ // Composite Provider // ============================================================================ /// Composite provider: tries multiple providers in order pub struct CompositeRoleProvider { providers: Vec>, } impl CompositeRoleProvider { pub fn new(providers: Vec>) -> Self { Self { providers } } } #[async_trait] impl RoleProvider for CompositeRoleProvider { async fn get_role(&self, name: &str) -> Result> { for provider in &self.providers { if let Some(role) = provider.get_role(name).await? { return Ok(Some(role)); } } Ok(None) } async fn list_roles(&self) -> Result> { let mut all_roles = Vec::new(); for provider in &self.providers { let roles = provider.list_roles().await?; for role in roles { if !all_roles.contains(&role) { all_roles.push(role); } } } Ok(all_roles) } async fn invalidate(&self, name: &str) -> Result<()> { for provider in &self.providers { provider.invalidate(name).await?; } Ok(()) } async fn invalidate_all(&self) -> Result<()> { for provider in &self.providers { provider.invalidate_all().await?; } Ok(()) } } // ============================================================================ // Built-in Roles // ============================================================================ use super::types::{AccessRule, AccessScope, Verb, Visibility, OwnerConstraint}; /// Create built-in admin role pub fn admin_role() -> Role { Role::new("admin") .with_description("Full access to all resources") .with_rule(AccessRule::new(vec!["*"], vec![Verb::Read, Verb::Write, Verb::Delete, Verb::Query])) } /// Create portfolio-agent role (public visitor access) pub fn portfolio_agent_role() -> Role { Role::new("portfolio-agent") .with_description("Public visitor access via portfolio agent") .with_rule( AccessRule::new(vec!["wiki", "embedding"], vec![Verb::Read, Verb::Query]) .with_scope(AccessScope::new() .with_projects(vec!["homelab".into(), "rbc".into(), "aws".into(), "portfolio".into()]) .with_visibility(Visibility::Public)) ) .with_rule( AccessRule::new(vec!["conversation"], vec![Verb::Read, Verb::Write]) .with_scope(AccessScope::new() .with_projects(vec!["portfolio".into()]) .with_owner(OwnerConstraint::SelfOwned)) ) } /// Create authenticated-user role pub fn authenticated_user_role() -> Role { Role::new("authenticated-user") .with_description("Logged in user with access to public + private resources") .with_rule( AccessRule::new(vec!["wiki", "embedding", "skill"], vec![Verb::Read, Verb::Query]) ) .with_rule( AccessRule::new(vec!["conversation"], vec![Verb::Read, Verb::Write, Verb::Delete]) .with_scope(AccessScope::new() .with_owner(OwnerConstraint::SelfOwned)) ) } /// Provider with built-in roles pub fn builtin_role_provider() -> InMemoryRoleProvider { InMemoryRoleProvider::new() .add_role(admin_role()) .add_role(portfolio_agent_role()) .add_role(authenticated_user_role()) } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_in_memory_provider() { let provider = InMemoryRoleProvider::new() .add_role(Role::new("test-role")); let role = provider.get_role("test-role").await.unwrap(); assert!(role.is_some()); assert_eq!(role.unwrap().name, "test-role"); let missing = provider.get_role("nonexistent").await.unwrap(); assert!(missing.is_none()); } #[tokio::test] async fn test_in_memory_list_roles() { let provider = InMemoryRoleProvider::new() .add_role(Role::new("role-a")) .add_role(Role::new("role-b")); let roles = provider.list_roles().await.unwrap(); assert_eq!(roles.len(), 2); assert!(roles.contains(&"role-a".to_string())); assert!(roles.contains(&"role-b".to_string())); } #[tokio::test] async fn test_in_memory_invalidate() { let provider = InMemoryRoleProvider::new() .add_role(Role::new("ephemeral")); assert!(provider.get_role("ephemeral").await.unwrap().is_some()); provider.invalidate("ephemeral").await.unwrap(); assert!(provider.get_role("ephemeral").await.unwrap().is_none()); } #[tokio::test] async fn test_get_roles_batch() { let provider = InMemoryRoleProvider::new() .add_role(Role::new("role-a")) .add_role(Role::new("role-b")) .add_role(Role::new("role-c")); let roles = provider.get_roles(&["role-a".into(), "role-c".into()]).await.unwrap(); assert_eq!(roles.len(), 2); } #[tokio::test] async fn test_builtin_roles() { let provider = builtin_role_provider(); let admin = provider.get_role("admin").await.unwrap().unwrap(); assert!(admin.allows(super::super::types::ResourceType::Wiki, Verb::Write)); let agent = provider.get_role("portfolio-agent").await.unwrap().unwrap(); assert!(agent.allows(super::super::types::ResourceType::Wiki, Verb::Read)); assert!(!agent.allows(super::super::types::ResourceType::Wiki, Verb::Write)); } #[tokio::test] async fn test_composite_provider() { let primary = Box::new(InMemoryRoleProvider::new() .add_role(Role::new("primary-only"))); let fallback = Box::new(InMemoryRoleProvider::new() .add_role(Role::new("fallback-only"))); let composite = CompositeRoleProvider::new(vec![primary, fallback]); assert!(composite.get_role("primary-only").await.unwrap().is_some()); assert!(composite.get_role("fallback-only").await.unwrap().is_some()); assert!(composite.get_role("nonexistent").await.unwrap().is_none()); } #[tokio::test] async fn test_admin_role_structure() { let admin = admin_role(); // Admin should have wildcard access assert!(admin.allows(super::super::types::ResourceType::Wiki, Verb::Read)); assert!(admin.allows(super::super::types::ResourceType::Wiki, Verb::Write)); assert!(admin.allows(super::super::types::ResourceType::Conversation, Verb::Delete)); } #[tokio::test] async fn test_portfolio_agent_scope() { let agent = portfolio_agent_role(); // Find wiki rule let wiki_rules = agent.rules_for_resource(super::super::types::ResourceType::Wiki); assert!(!wiki_rules.is_empty()); let wiki_rule = wiki_rules[0]; assert!(wiki_rule.scope.allows_project("homelab")); assert!(wiki_rule.scope.allows_project("portfolio")); assert!(!wiki_rule.scope.allows_project("secret-project")); assert_eq!(wiki_rule.scope.visibility, Some(Visibility::Public)); } }