Implements comprehensive RBAC system: Core Types (types.rs): - Role: named set of AccessRules - AccessRule: (resources, verbs, scope) tuple - AccessScope: project/visibility/owner/group constraints - ResourceMeta: document metadata for access checks - Verb: read/write/delete/query - Visibility: public/private per document Role Provider (role_provider.rs): - RoleProvider trait for pluggable backends - YamlRoleProvider: load from YAML files - InMemoryRoleProvider: for testing - CompositeRoleProvider: layered lookup - Built-in roles: admin, portfolio-agent, authenticated-user Scope Checker (scope_checker.rs): - ScopeChecker trait + composite pattern - ProjectScopeChecker: allowed projects list - VisibilityScopeChecker: public/private matching - OwnerScopeChecker: self/any/specific user - GroupScopeChecker: required group membership Access Guard (access_guard.rs): - Unified API for HTTP + retrieval layers - check_http_capability(): memory:read/write checks - filter_resources(): document-level filtering - Audit logging for all decisions Tests: 77 unit + 25 integration, all passing Migration note: AuthorizedPipeline retained for compatibility, will be replaced by AccessGuard integration in next phase.
433 lines
13 KiB
Rust
433 lines
13 KiB
Rust
/// 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<Option<Role>>;
|
|
|
|
/// Get all roles for a list of role names
|
|
async fn get_roles(&self, names: &[String]) -> Result<Vec<Role>> {
|
|
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<Vec<String>>;
|
|
|
|
/// 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<HashMap<String, Role>>,
|
|
}
|
|
|
|
impl YamlRoleProvider {
|
|
pub fn new(roles_dir: impl AsRef<Path>) -> 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<Option<Role>> {
|
|
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<Option<Role>> {
|
|
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<Option<Role>> {
|
|
// 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<Vec<String>> {
|
|
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<HashMap<String, Role>>,
|
|
}
|
|
|
|
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<Role>) -> 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<Option<Role>> {
|
|
let roles = self.roles.read().unwrap();
|
|
Ok(roles.get(name).cloned())
|
|
}
|
|
|
|
async fn list_roles(&self) -> Result<Vec<String>> {
|
|
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<Box<dyn RoleProvider>>,
|
|
}
|
|
|
|
impl CompositeRoleProvider {
|
|
pub fn new(providers: Vec<Box<dyn RoleProvider>>) -> Self {
|
|
Self { providers }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl RoleProvider for CompositeRoleProvider {
|
|
async fn get_role(&self, name: &str) -> Result<Option<Role>> {
|
|
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<Vec<String>> {
|
|
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));
|
|
}
|
|
}
|