Files
poimen-memory/crates/mem-cli/src/rbac/role_provider.rs
T
rock 2448e5ebe2 feat(rbac): hierarchical access control with fine-grained scopes
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.
2026-08-31 23:22:11 -07:00

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));
}
}