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.
This commit is contained in:
2026-08-31 23:22:11 -07:00
parent cf409718b1
commit 56f8e8b391
11 changed files with 4478 additions and 10 deletions
+469
View File
@@ -0,0 +1,469 @@
/// Access Evaluator: Orchestrate all access checks
///
/// Flow:
/// 1. Resolve roles from claims
/// 2. For each role, find matching rules
/// 3. For each rule, check verb + scope
/// 4. First match wins (allow), else deny
use std::sync::Arc;
use super::role_provider::RoleProvider;
use super::scope_checker::{CompositeScopeChecker, ScopeResult};
use super::types::{AccessDecision, Claims, DenyReason, ResourceMeta, Role, Verb};
pub use super::types::HasResourceMeta;
// ============================================================================
// Access Evaluator
// ============================================================================
/// Evaluates access requests against roles and scopes
pub struct AccessEvaluator {
role_provider: Arc<dyn RoleProvider>,
scope_checker: CompositeScopeChecker,
}
impl AccessEvaluator {
pub fn new(role_provider: Arc<dyn RoleProvider>) -> Self {
Self {
role_provider,
scope_checker: CompositeScopeChecker::new(),
}
}
/// Custom scope checker
pub fn with_scope_checker(mut self, checker: CompositeScopeChecker) -> Self {
self.scope_checker = checker;
self
}
/// Evaluate access for a single resource
pub async fn evaluate(
&self,
claims: &Claims,
resource: &ResourceMeta,
verb: Verb,
) -> AccessDecision {
// Get all roles for this user
let roles = match self.role_provider.get_roles(&claims.roles).await {
Ok(roles) => roles,
Err(_) => return AccessDecision::Deny { reason: DenyReason::NoMatchingRole },
};
if roles.is_empty() {
return AccessDecision::Deny { reason: DenyReason::NoMatchingRole };
}
// Check each role
for role in &roles {
if let Some(decision) = self.evaluate_role(claims, resource, verb, role) {
if decision.is_allowed() {
return decision;
}
}
}
AccessDecision::Deny { reason: DenyReason::NoMatchingRule }
}
/// Evaluate a single role
fn evaluate_role(
&self,
claims: &Claims,
resource: &ResourceMeta,
verb: Verb,
role: &Role,
) -> Option<AccessDecision> {
// Find rules that match the resource type
let matching_rules = role.rules_for_resource(resource.resource_type);
for (rule_idx, rule) in matching_rules.iter().enumerate() {
// Check verb
if !rule.allows_verb(verb) {
continue;
}
// Check scope
match self.scope_checker.check_all(claims, resource, &rule.scope) {
ScopeResult::Pass | ScopeResult::NotApplicable => {
return Some(AccessDecision::Allow {
matched_role: role.name.clone(),
matched_rule_index: rule_idx,
});
}
ScopeResult::Fail(_) => continue,
}
}
None
}
/// Evaluate capability-level access (without specific resource)
/// Used for HTTP endpoint checks like "can user read anything?"
pub async fn evaluate_capability(&self, claims: &Claims, verb: Verb) -> bool {
let roles = match self.role_provider.get_roles(&claims.roles).await {
Ok(roles) => roles,
Err(_) => return false,
};
for role in &roles {
for rule in &role.rules {
if rule.allows_verb(verb) {
return true;
}
}
}
false
}
/// Filter resources by access
pub async fn filter<T: HasResourceMeta>(
&self,
claims: &Claims,
verb: Verb,
resources: Vec<T>,
) -> FilterResult<T> {
let mut allowed = Vec::new();
let mut denied = Vec::new();
for resource in resources {
let meta = resource.resource_meta();
match self.evaluate(claims, meta, verb).await {
AccessDecision::Allow { .. } => allowed.push(resource),
AccessDecision::Deny { reason } => {
denied.push((meta.id.clone(), reason));
}
}
}
FilterResult { allowed, denied }
}
/// Batch evaluation for multiple resources (optimized)
pub async fn evaluate_batch(
&self,
claims: &Claims,
resources: &[ResourceMeta],
verb: Verb,
) -> Vec<AccessDecision> {
// Pre-fetch roles once
let roles = match self.role_provider.get_roles(&claims.roles).await {
Ok(roles) => roles,
Err(_) => {
return resources
.iter()
.map(|_| AccessDecision::Deny { reason: DenyReason::NoMatchingRole })
.collect();
}
};
if roles.is_empty() {
return resources
.iter()
.map(|_| AccessDecision::Deny { reason: DenyReason::NoMatchingRole })
.collect();
}
resources
.iter()
.map(|resource| {
for role in &roles {
if let Some(decision) = self.evaluate_role(claims, resource, verb, role) {
if decision.is_allowed() {
return decision;
}
}
}
AccessDecision::Deny { reason: DenyReason::NoMatchingRule }
})
.collect()
}
}
/// Result of filtering resources by access
#[derive(Debug)]
pub struct FilterResult<T> {
pub allowed: Vec<T>,
pub denied: Vec<(String, DenyReason)>,
}
impl<T> FilterResult<T> {
pub fn all_allowed(&self) -> bool {
self.denied.is_empty()
}
pub fn none_allowed(&self) -> bool {
self.allowed.is_empty()
}
pub fn stats(&self) -> (usize, usize) {
(self.allowed.len(), self.denied.len())
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::rbac::role_provider::{admin_role, authenticated_user_role, portfolio_agent_role, InMemoryRoleProvider};
use crate::rbac::types::{AccessRule, AccessScope, ResourceType, Visibility};
fn test_provider() -> Arc<InMemoryRoleProvider> {
Arc::new(
InMemoryRoleProvider::new()
.add_role(admin_role())
.add_role(portfolio_agent_role())
.add_role(authenticated_user_role())
)
}
// ========================================================================
// Admin Tests
// ========================================================================
#[tokio::test]
async fn test_admin_full_access() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("root").with_roles(vec!["admin"]);
let wiki = ResourceMeta::wiki("doc-1", "secret-project")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &wiki, Verb::Write).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_admin_delete_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("root").with_roles(vec!["admin"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "other-user");
let result = evaluator.evaluate(&claims, &conv, Verb::Delete).await;
assert!(result.is_allowed());
}
// ========================================================================
// Portfolio Agent Tests
// ========================================================================
#[tokio::test]
async fn test_portfolio_agent_read_public_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_portfolio_agent_denied_private_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("secret", "homelab")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
}
#[tokio::test]
async fn test_portfolio_agent_denied_wrong_project() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "secret-project")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
}
#[tokio::test]
async fn test_portfolio_agent_own_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "visitor-123");
let result = evaluator.evaluate(&claims, &conv, Verb::Write).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_portfolio_agent_denied_other_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "other-user");
let result = evaluator.evaluate(&claims, &conv, Verb::Read).await;
assert!(result.is_denied());
}
#[tokio::test]
async fn test_portfolio_agent_denied_write_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Write).await;
assert!(result.is_denied());
}
// ========================================================================
// Authenticated User Tests
// ========================================================================
#[tokio::test]
async fn test_auth_user_read_any_wiki() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("alice").with_roles(vec!["authenticated-user"]);
// Can read private wiki in any project
let wiki = ResourceMeta::wiki("secret", "secret-project")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_allowed());
}
#[tokio::test]
async fn test_auth_user_own_conversation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("alice").with_roles(vec!["authenticated-user"]);
let conv = ResourceMeta::conversation("conv-1", "any-project", "alice");
// Can read, write, delete own conversations
assert!(evaluator.evaluate(&claims, &conv, Verb::Read).await.is_allowed());
assert!(evaluator.evaluate(&claims, &conv, Verb::Write).await.is_allowed());
assert!(evaluator.evaluate(&claims, &conv, Verb::Delete).await.is_allowed());
}
// ========================================================================
// No Role Tests
// ========================================================================
#[tokio::test]
async fn test_no_role_denied() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("anonymous"); // No roles
let wiki = ResourceMeta::wiki("public", "homelab")
.with_visibility(Visibility::Public);
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
assert!(matches!(result, AccessDecision::Deny { reason: DenyReason::NoMatchingRole }));
}
#[tokio::test]
async fn test_unknown_role_denied() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("user").with_roles(vec!["nonexistent-role"]);
let wiki = ResourceMeta::wiki("public", "homelab");
let result = evaluator.evaluate(&claims, &wiki, Verb::Read).await;
assert!(result.is_denied());
}
// ========================================================================
// Capability Tests
// ========================================================================
#[tokio::test]
async fn test_capability_admin_can_write() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("root").with_roles(vec!["admin"]);
assert!(evaluator.evaluate_capability(&claims, Verb::Write).await);
assert!(evaluator.evaluate_capability(&claims, Verb::Delete).await);
}
#[tokio::test]
async fn test_capability_portfolio_agent_read_only() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
assert!(evaluator.evaluate_capability(&claims, Verb::Read).await);
assert!(evaluator.evaluate_capability(&claims, Verb::Query).await);
assert!(evaluator.evaluate_capability(&claims, Verb::Write).await); // Has write for conversations
assert!(!evaluator.evaluate_capability(&claims, Verb::Delete).await);
}
// ========================================================================
// Filter Tests
// ========================================================================
#[tokio::test]
async fn test_filter_resources() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let resources = vec![
ResourceMeta::wiki("public-1", "homelab").with_visibility(Visibility::Public),
ResourceMeta::wiki("private-1", "homelab").with_visibility(Visibility::Private),
ResourceMeta::wiki("public-2", "portfolio").with_visibility(Visibility::Public),
ResourceMeta::wiki("wrong-project", "secret").with_visibility(Visibility::Public),
];
let result = evaluator.filter(&claims, Verb::Read, resources).await;
assert_eq!(result.allowed.len(), 2); // public-1, public-2
assert_eq!(result.denied.len(), 2); // private-1, wrong-project
}
// ========================================================================
// Batch Evaluation Tests
// ========================================================================
#[tokio::test]
async fn test_batch_evaluation() {
let evaluator = AccessEvaluator::new(test_provider());
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let resources = vec![
ResourceMeta::wiki("public", "homelab").with_visibility(Visibility::Public),
ResourceMeta::wiki("private", "homelab").with_visibility(Visibility::Private),
];
let decisions = evaluator.evaluate_batch(&claims, &resources, Verb::Read).await;
assert_eq!(decisions.len(), 2);
assert!(decisions[0].is_allowed());
assert!(decisions[1].is_denied());
}
// ========================================================================
// Multiple Roles Tests
// ========================================================================
#[tokio::test]
async fn test_multiple_roles_combined() {
let evaluator = AccessEvaluator::new(test_provider());
// User with both portfolio-agent and authenticated-user roles
let claims = Claims::new("power-user")
.with_roles(vec!["portfolio-agent", "authenticated-user"]);
// Private wiki - denied by portfolio-agent, allowed by authenticated-user
let private_wiki = ResourceMeta::wiki("secret", "homelab")
.with_visibility(Visibility::Private);
let result = evaluator.evaluate(&claims, &private_wiki, Verb::Read).await;
assert!(result.is_allowed()); // authenticated-user allows it
}
}
+515
View File
@@ -0,0 +1,515 @@
/// Access Guard: Unified RBAC API
///
/// Single entry point for all access control:
/// - HTTP layer: check_capability() for memory:read/write
/// - Retrieval: filter_resources() for document-level access
/// - Audit: all decisions logged
use std::sync::Arc;
use anyhow::Result;
use super::access_evaluator::{AccessEvaluator, FilterResult, HasResourceMeta};
use super::role_provider::RoleProvider;
use super::types::{AccessDecision, Claims, DenyReason, ResourceMeta, Verb};
// ============================================================================
// Audit Logger
// ============================================================================
/// Audit log entry
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub user_id: String,
pub action: Verb,
pub resource_type: String,
pub resource_id: String,
pub project: String,
pub decision: String,
pub reason: Option<String>,
pub matched_role: Option<String>,
}
/// Trait for audit logging
pub trait AuditLogger: Send + Sync {
fn log(&self, entry: AuditEntry);
}
/// No-op audit logger (for testing)
pub struct NoOpAuditLogger;
impl AuditLogger for NoOpAuditLogger {
fn log(&self, _entry: AuditEntry) {}
}
/// In-memory audit logger (for testing/debugging)
pub struct InMemoryAuditLogger {
entries: std::sync::RwLock<Vec<AuditEntry>>,
}
impl InMemoryAuditLogger {
pub fn new() -> Self {
Self {
entries: std::sync::RwLock::new(Vec::new()),
}
}
pub fn entries(&self) -> Vec<AuditEntry> {
self.entries.read().unwrap().clone()
}
pub fn clear(&self) {
self.entries.write().unwrap().clear();
}
}
impl Default for InMemoryAuditLogger {
fn default() -> Self {
Self::new()
}
}
impl AuditLogger for InMemoryAuditLogger {
fn log(&self, entry: AuditEntry) {
self.entries.write().unwrap().push(entry);
}
}
// ============================================================================
// Access Guard
// ============================================================================
/// Unified RBAC API
pub struct AccessGuard {
evaluator: AccessEvaluator,
audit: Arc<dyn AuditLogger>,
}
impl AccessGuard {
pub fn new(role_provider: Arc<dyn RoleProvider>) -> Self {
Self {
evaluator: AccessEvaluator::new(role_provider),
audit: Arc::new(NoOpAuditLogger),
}
}
pub fn with_audit(mut self, audit: Arc<dyn AuditLogger>) -> Self {
self.audit = audit;
self
}
// ========================================================================
// Capability Level (HTTP Layer)
// ========================================================================
/// Check if user has capability for a verb (e.g., "can user write anything?")
/// Used by HTTP endpoints before processing requests
pub async fn check_capability(&self, claims: &Claims, verb: Verb) -> bool {
let result = self.evaluator.evaluate_capability(claims, verb).await;
self.audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
user_id: claims.sub.clone(),
action: verb,
resource_type: "*".to_string(),
resource_id: "*".to_string(),
project: "*".to_string(),
decision: if result { "allow" } else { "deny" }.to_string(),
reason: if result { None } else { Some("no_matching_capability".to_string()) },
matched_role: None,
});
result
}
/// Map HTTP capability string to Verb
pub fn verb_from_capability(capability: &str) -> Option<Verb> {
match capability {
"memory:read" => Some(Verb::Read),
"memory:write" => Some(Verb::Write),
"memory:delete" => Some(Verb::Delete),
"memory:query" => Some(Verb::Query),
_ => None,
}
}
/// Check HTTP-style capability (e.g., "memory:read")
pub async fn check_http_capability(&self, claims: &Claims, capability: &str) -> bool {
// Wildcard permission
if claims.has_permission("*") {
return true;
}
// Direct permission check
if claims.has_permission(capability) {
return true;
}
// Role-based check
if let Some(verb) = Self::verb_from_capability(capability) {
return self.check_capability(claims, verb).await;
}
false
}
// ========================================================================
// Resource Level (Retrieval Layer)
// ========================================================================
/// Check access to a single resource
pub async fn check_access(
&self,
claims: &Claims,
resource: &ResourceMeta,
verb: Verb,
) -> AccessDecision {
let decision = self.evaluator.evaluate(claims, resource, verb).await;
self.audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
user_id: claims.sub.clone(),
action: verb,
resource_type: resource.resource_type.as_str().to_string(),
resource_id: resource.id.clone(),
project: resource.project.clone(),
decision: if decision.is_allowed() { "allow" } else { "deny" }.to_string(),
reason: match &decision {
AccessDecision::Deny { reason } => Some(reason.to_string()),
_ => None,
},
matched_role: match &decision {
AccessDecision::Allow { matched_role, .. } => Some(matched_role.clone()),
_ => None,
},
});
decision
}
/// Filter resources by access
pub async fn filter_resources<T: HasResourceMeta>(
&self,
claims: &Claims,
verb: Verb,
resources: Vec<T>,
) -> FilterResult<T> {
self.evaluator.filter(claims, verb, resources).await
}
/// Batch check access to multiple resources
pub async fn check_access_batch(
&self,
claims: &Claims,
resources: &[ResourceMeta],
verb: Verb,
) -> Vec<AccessDecision> {
let decisions = self.evaluator.evaluate_batch(claims, resources, verb).await;
// Log each decision
for (resource, decision) in resources.iter().zip(decisions.iter()) {
self.audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
user_id: claims.sub.clone(),
action: verb,
resource_type: resource.resource_type.as_str().to_string(),
resource_id: resource.id.clone(),
project: resource.project.clone(),
decision: if decision.is_allowed() { "allow" } else { "deny" }.to_string(),
reason: match decision {
AccessDecision::Deny { reason } => Some(reason.to_string()),
_ => None,
},
matched_role: match decision {
AccessDecision::Allow { matched_role, .. } => Some(matched_role.clone()),
_ => None,
},
});
}
decisions
}
// ========================================================================
// Convenience Methods
// ========================================================================
/// Check if user can read a resource
pub async fn can_read(&self, claims: &Claims, resource: &ResourceMeta) -> bool {
self.check_access(claims, resource, Verb::Read).await.is_allowed()
}
/// Check if user can write to a resource
pub async fn can_write(&self, claims: &Claims, resource: &ResourceMeta) -> bool {
self.check_access(claims, resource, Verb::Write).await.is_allowed()
}
/// Check if user can query embeddings
pub async fn can_query(&self, claims: &Claims, resource: &ResourceMeta) -> bool {
self.check_access(claims, resource, Verb::Query).await.is_allowed()
}
/// Check if user can delete a resource
pub async fn can_delete(&self, claims: &Claims, resource: &ResourceMeta) -> bool {
self.check_access(claims, resource, Verb::Delete).await.is_allowed()
}
}
// ============================================================================
// Builder
// ============================================================================
/// Builder for AccessGuard
pub struct AccessGuardBuilder {
role_provider: Option<Arc<dyn RoleProvider>>,
audit: Option<Arc<dyn AuditLogger>>,
}
impl AccessGuardBuilder {
pub fn new() -> Self {
Self {
role_provider: None,
audit: None,
}
}
pub fn with_role_provider(mut self, provider: Arc<dyn RoleProvider>) -> Self {
self.role_provider = Some(provider);
self
}
pub fn with_audit(mut self, audit: Arc<dyn AuditLogger>) -> Self {
self.audit = Some(audit);
self
}
pub fn build(self) -> Result<AccessGuard> {
let provider = self.role_provider
.ok_or_else(|| anyhow::anyhow!("RoleProvider required"))?;
let mut guard = AccessGuard::new(provider);
if let Some(audit) = self.audit {
guard = guard.with_audit(audit);
}
Ok(guard)
}
}
impl Default for AccessGuardBuilder {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::rbac::role_provider::{builtin_role_provider, InMemoryRoleProvider};
use crate::rbac::types::{ResourceType, Visibility};
fn test_guard() -> AccessGuard {
AccessGuard::new(Arc::new(builtin_role_provider()))
}
fn guard_with_audit() -> (AccessGuard, Arc<InMemoryAuditLogger>) {
let audit = Arc::new(InMemoryAuditLogger::new());
let guard = AccessGuard::new(Arc::new(builtin_role_provider()))
.with_audit(audit.clone());
(guard, audit)
}
// ========================================================================
// Capability Tests
// ========================================================================
#[tokio::test]
async fn test_check_capability_admin() {
let guard = test_guard();
let claims = Claims::new("admin-user").with_roles(vec!["admin"]);
assert!(guard.check_capability(&claims, Verb::Read).await);
assert!(guard.check_capability(&claims, Verb::Write).await);
assert!(guard.check_capability(&claims, Verb::Delete).await);
}
#[tokio::test]
async fn test_check_capability_portfolio_agent() {
let guard = test_guard();
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
assert!(guard.check_capability(&claims, Verb::Read).await);
assert!(guard.check_capability(&claims, Verb::Query).await);
assert!(!guard.check_capability(&claims, Verb::Delete).await);
}
#[tokio::test]
async fn test_check_http_capability_wildcard() {
let guard = test_guard();
let claims = Claims::new("superuser")
.with_permissions(vec!["*"]);
assert!(guard.check_http_capability(&claims, "memory:read").await);
assert!(guard.check_http_capability(&claims, "memory:write").await);
assert!(guard.check_http_capability(&claims, "anything:else").await);
}
#[tokio::test]
async fn test_check_http_capability_direct_permission() {
let guard = test_guard();
let claims = Claims::new("api-client")
.with_permissions(vec!["memory:read"]);
assert!(guard.check_http_capability(&claims, "memory:read").await);
assert!(!guard.check_http_capability(&claims, "memory:write").await);
}
// ========================================================================
// Resource Access Tests
// ========================================================================
#[tokio::test]
async fn test_can_read_public() {
let guard = test_guard();
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
assert!(guard.can_read(&claims, &wiki).await);
}
#[tokio::test]
async fn test_cannot_write_as_visitor() {
let guard = test_guard();
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
assert!(!guard.can_write(&claims, &wiki).await);
}
#[tokio::test]
async fn test_can_write_own_conversation() {
let guard = test_guard();
let claims = Claims::new("visitor-123").with_roles(vec!["portfolio-agent"]);
let conv = ResourceMeta::conversation("conv-1", "portfolio", "visitor-123");
assert!(guard.can_write(&claims, &conv).await);
}
// ========================================================================
// Audit Tests
// ========================================================================
#[tokio::test]
async fn test_audit_logging() {
let (guard, audit) = guard_with_audit();
let claims = Claims::new("test-user").with_roles(vec!["admin"]);
let wiki = ResourceMeta::wiki("doc-1", "homelab");
guard.check_access(&claims, &wiki, Verb::Read).await;
let entries = audit.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].user_id, "test-user");
assert_eq!(entries[0].decision, "allow");
assert_eq!(entries[0].matched_role, Some("admin".to_string()));
}
#[tokio::test]
async fn test_audit_denied() {
let (guard, audit) = guard_with_audit();
let claims = Claims::new("no-role");
let wiki = ResourceMeta::wiki("doc-1", "homelab");
guard.check_access(&claims, &wiki, Verb::Read).await;
let entries = audit.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].decision, "deny");
assert!(entries[0].reason.is_some());
}
#[tokio::test]
async fn test_audit_batch() {
let (guard, audit) = guard_with_audit();
let claims = Claims::new("admin-user").with_roles(vec!["admin"]);
let resources = vec![
ResourceMeta::wiki("doc-1", "homelab"),
ResourceMeta::wiki("doc-2", "homelab"),
];
guard.check_access_batch(&claims, &resources, Verb::Read).await;
let entries = audit.entries();
assert_eq!(entries.len(), 2);
}
// ========================================================================
// Filter Tests
// ========================================================================
#[tokio::test]
async fn test_filter_resources() {
let guard = test_guard();
let claims = Claims::new("visitor").with_roles(vec!["portfolio-agent"]);
let resources = vec![
ResourceMeta::wiki("public-1", "homelab").with_visibility(Visibility::Public),
ResourceMeta::wiki("private-1", "homelab").with_visibility(Visibility::Private),
ResourceMeta::wiki("public-2", "portfolio").with_visibility(Visibility::Public),
];
let result = guard.filter_resources(&claims, Verb::Read, resources).await;
assert_eq!(result.allowed.len(), 2);
assert_eq!(result.denied.len(), 1);
assert!(result.allowed.iter().any(|r| r.id == "public-1"));
assert!(result.allowed.iter().any(|r| r.id == "public-2"));
}
// ========================================================================
// Builder Tests
// ========================================================================
#[tokio::test]
async fn test_builder() {
let provider = Arc::new(builtin_role_provider());
let audit = Arc::new(InMemoryAuditLogger::new());
let guard = AccessGuardBuilder::new()
.with_role_provider(provider)
.with_audit(audit.clone())
.build()
.unwrap();
let claims = Claims::new("admin").with_roles(vec!["admin"]);
assert!(guard.check_capability(&claims, Verb::Read).await);
}
#[tokio::test]
async fn test_builder_missing_provider() {
let result = AccessGuardBuilder::new().build();
assert!(result.is_err());
}
// ========================================================================
// Verb Mapping Tests
// ========================================================================
#[test]
fn test_verb_from_capability() {
assert_eq!(AccessGuard::verb_from_capability("memory:read"), Some(Verb::Read));
assert_eq!(AccessGuard::verb_from_capability("memory:write"), Some(Verb::Write));
assert_eq!(AccessGuard::verb_from_capability("memory:delete"), Some(Verb::Delete));
assert_eq!(AccessGuard::verb_from_capability("memory:query"), Some(Verb::Query));
assert_eq!(AccessGuard::verb_from_capability("unknown"), None);
}
}
+75 -6
View File
@@ -1,13 +1,82 @@
/// RBAC Module: Access control with OIDC + Vault policies
/// RBAC Module: Hierarchical access control
///
/// Phase 7 implementation: universal authentication + authorization
/// Depends on Authentik (OIDC) + Vault (policy files)
/// Architecture:
/// ```text
/// JWT Claims → RoleResolver → AccessEvaluator → AccessDecision
/// ↓ ↓
/// RoleProvider ScopeChecker
/// ↓ ↓
/// (YAML/Postgres) (Project/Visibility/Owner/Group)
/// ```
///
/// Usage:
/// ```ignore
/// let guard = AccessGuard::new(role_provider);
///
/// // HTTP layer: capability check
/// if !guard.check_http_capability(&claims, "memory:read").await {
/// return Err(Forbidden);
/// }
///
/// // Retrieval layer: resource filtering
/// let allowed = guard.filter_resources(&claims, Verb::Read, chunks).await;
/// ```
// Core types
pub mod types;
// Role loading
pub mod role_provider;
// Scope evaluation
pub mod scope_checker;
// Access evaluation
pub mod access_evaluator;
// Unified API
pub mod access_guard;
// Legacy (deprecated, will be removed)
pub mod policy_provider;
pub mod access_checker;
pub use policy_provider::{AccessPolicy, PolicyProvider, VaultPolicyProvider, MockPolicyProvider};
// ============================================================================
// Public Exports
// ============================================================================
// Types
pub use types::{
AccessDecision, AccessRule, AccessScope, Claims, DenyReason,
OwnerConstraint, ResourceMeta, ResourceType, Role, Verb, Visibility,
HasResourceMeta,
};
// Role Provider
pub use role_provider::{
builtin_role_provider, admin_role, authenticated_user_role, portfolio_agent_role,
CompositeRoleProvider, InMemoryRoleProvider, RoleProvider, YamlRoleProvider,
};
// Scope Checker
pub use scope_checker::{
CompositeScopeChecker, GroupScopeChecker, OwnerScopeChecker, ProjectScopeChecker,
ScopeChecker, ScopeResult, VisibilityScopeChecker,
};
// Access Evaluator
pub use access_evaluator::{AccessEvaluator, FilterResult};
// Access Guard (main API)
pub use access_guard::{
AccessGuard, AccessGuardBuilder, AuditEntry, AuditLogger, InMemoryAuditLogger, NoOpAuditLogger,
};
// Legacy exports (deprecated)
pub use policy_provider::{AccessPolicy, MockPolicyProvider, PolicyProvider, VaultPolicyProvider};
pub use access_checker::{
AccessDecisionEngine, AccessChecker, AccessLevelChecker, RoleChecker, PermissionChecker,
AuditLogger, AccessDecision,
AccessChecker, AccessDecisionEngine, AccessLevelChecker, OidcClaims,
PermissionChecker, RoleChecker,
AuditLogger as LegacyAuditLogger, AccessDecision as LegacyAccessDecision,
NoOpAuditLogger as LegacyNoOpAuditLogger,
};
+432
View File
@@ -0,0 +1,432 @@
/// 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));
}
}
+499
View File
@@ -0,0 +1,499 @@
/// Scope Checker: Evaluate access scope constraints
///
/// Checks:
/// - ProjectScope: resource.project in allowed_projects?
/// - VisibilityScope: resource.visibility matches?
/// - OwnerScope: resource.owner == claims.sub?
/// - GroupScope: user in required groups?
use super::types::{AccessScope, Claims, DenyReason, OwnerConstraint, ResourceMeta, Visibility};
// ============================================================================
// Trait
// ============================================================================
/// Check if a scope constraint is satisfied
pub trait ScopeChecker: Send + Sync {
/// Check if the scope allows access to the resource
fn check(&self, claims: &Claims, resource: &ResourceMeta, scope: &AccessScope) -> ScopeResult;
/// Human-readable name for this checker
fn name(&self) -> &'static str;
}
/// Result of a scope check
#[derive(Debug, Clone, PartialEq)]
pub enum ScopeResult {
Pass,
Fail(DenyReason),
/// This checker doesn't apply (no constraint defined)
NotApplicable,
}
impl ScopeResult {
pub fn is_pass(&self) -> bool {
matches!(self, ScopeResult::Pass | ScopeResult::NotApplicable)
}
pub fn is_fail(&self) -> bool {
matches!(self, ScopeResult::Fail(_))
}
}
// ============================================================================
// Project Scope Checker
// ============================================================================
/// Check if resource's project is in allowed projects
pub struct ProjectScopeChecker;
impl ScopeChecker for ProjectScopeChecker {
fn check(&self, _claims: &Claims, resource: &ResourceMeta, scope: &AccessScope) -> ScopeResult {
match &scope.projects {
None => ScopeResult::NotApplicable,
Some(projects) => {
// Wildcard allows all
if projects.iter().any(|p| p == "*") {
return ScopeResult::Pass;
}
// Check if resource's project is in list
if projects.iter().any(|p| p == &resource.project) {
ScopeResult::Pass
} else {
ScopeResult::Fail(DenyReason::ProjectNotAllowed)
}
}
}
}
fn name(&self) -> &'static str {
"project"
}
}
// ============================================================================
// Visibility Scope Checker
// ============================================================================
/// Check if resource's visibility matches required visibility
pub struct VisibilityScopeChecker;
impl ScopeChecker for VisibilityScopeChecker {
fn check(&self, _claims: &Claims, resource: &ResourceMeta, scope: &AccessScope) -> ScopeResult {
match &scope.visibility {
None => ScopeResult::NotApplicable,
Some(required) => {
if resource.visibility == *required {
ScopeResult::Pass
} else {
ScopeResult::Fail(DenyReason::VisibilityMismatch)
}
}
}
}
fn name(&self) -> &'static str {
"visibility"
}
}
// ============================================================================
// Owner Scope Checker
// ============================================================================
/// Check if resource's owner matches constraint
pub struct OwnerScopeChecker;
impl ScopeChecker for OwnerScopeChecker {
fn check(&self, claims: &Claims, resource: &ResourceMeta, scope: &AccessScope) -> ScopeResult {
match &scope.owner {
None => ScopeResult::NotApplicable,
Some(constraint) => {
match constraint {
OwnerConstraint::Any => ScopeResult::Pass,
OwnerConstraint::SelfOwned => {
match &resource.owner {
None => ScopeResult::Fail(DenyReason::OwnerMismatch),
Some(owner) => {
if owner == &claims.sub {
ScopeResult::Pass
} else {
ScopeResult::Fail(DenyReason::OwnerMismatch)
}
}
}
}
OwnerConstraint::User(user_id) => {
match &resource.owner {
None => ScopeResult::Fail(DenyReason::OwnerMismatch),
Some(owner) => {
if owner == user_id {
ScopeResult::Pass
} else {
ScopeResult::Fail(DenyReason::OwnerMismatch)
}
}
}
}
}
}
}
}
fn name(&self) -> &'static str {
"owner"
}
}
// ============================================================================
// Group Scope Checker
// ============================================================================
/// Check if user is in required groups
pub struct GroupScopeChecker;
impl ScopeChecker for GroupScopeChecker {
fn check(&self, claims: &Claims, _resource: &ResourceMeta, scope: &AccessScope) -> ScopeResult {
match &scope.groups {
None => ScopeResult::NotApplicable,
Some(required_groups) => {
if required_groups.is_empty() {
return ScopeResult::NotApplicable;
}
// User must be in at least one required group
if claims.in_any_group(required_groups) {
ScopeResult::Pass
} else {
ScopeResult::Fail(DenyReason::GroupRequired)
}
}
}
}
fn name(&self) -> &'static str {
"group"
}
}
// ============================================================================
// Composite Scope Checker
// ============================================================================
/// Evaluates all scope checkers, all must pass (or be not applicable)
pub struct CompositeScopeChecker {
checkers: Vec<Box<dyn ScopeChecker>>,
}
impl CompositeScopeChecker {
pub fn new() -> Self {
Self {
checkers: vec![
Box::new(ProjectScopeChecker),
Box::new(VisibilityScopeChecker),
Box::new(OwnerScopeChecker),
Box::new(GroupScopeChecker),
],
}
}
/// Add a custom checker
pub fn with_checker(mut self, checker: Box<dyn ScopeChecker>) -> Self {
self.checkers.push(checker);
self
}
/// Evaluate all checkers
pub fn check_all(
&self,
claims: &Claims,
resource: &ResourceMeta,
scope: &AccessScope,
) -> ScopeResult {
for checker in &self.checkers {
match checker.check(claims, resource, scope) {
ScopeResult::Fail(reason) => return ScopeResult::Fail(reason),
ScopeResult::Pass | ScopeResult::NotApplicable => continue,
}
}
ScopeResult::Pass
}
/// Get detailed results from all checkers
pub fn check_all_detailed(
&self,
claims: &Claims,
resource: &ResourceMeta,
scope: &AccessScope,
) -> Vec<(&'static str, ScopeResult)> {
self.checkers
.iter()
.map(|c| (c.name(), c.check(claims, resource, scope)))
.collect()
}
}
impl Default for CompositeScopeChecker {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::rbac::types::ResourceType;
fn test_claims() -> Claims {
Claims::new("alice")
.with_groups(vec!["engineering", "ml-team"])
}
fn test_resource() -> ResourceMeta {
ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public)
}
// ========================================================================
// Project Scope Tests
// ========================================================================
#[test]
fn test_project_scope_pass() {
let checker = ProjectScopeChecker;
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new().with_projects(vec!["homelab".into(), "rbc".into()]);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_project_scope_fail() {
let checker = ProjectScopeChecker;
let claims = test_claims();
let resource = test_resource(); // project = homelab
let scope = AccessScope::new().with_projects(vec!["secret".into()]);
assert!(matches!(
checker.check(&claims, &resource, &scope),
ScopeResult::Fail(DenyReason::ProjectNotAllowed)
));
}
#[test]
fn test_project_scope_wildcard() {
let checker = ProjectScopeChecker;
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new().with_projects(vec!["*".into()]);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_project_scope_not_applicable() {
let checker = ProjectScopeChecker;
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new(); // No projects constraint
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::NotApplicable);
}
// ========================================================================
// Visibility Scope Tests
// ========================================================================
#[test]
fn test_visibility_scope_public_pass() {
let checker = VisibilityScopeChecker;
let claims = test_claims();
let resource = test_resource(); // visibility = Public
let scope = AccessScope::new().with_visibility(Visibility::Public);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_visibility_scope_mismatch() {
let checker = VisibilityScopeChecker;
let claims = test_claims();
let resource = test_resource(); // visibility = Public
let scope = AccessScope::new().with_visibility(Visibility::Private);
assert!(matches!(
checker.check(&claims, &resource, &scope),
ScopeResult::Fail(DenyReason::VisibilityMismatch)
));
}
#[test]
fn test_visibility_scope_private_resource() {
let checker = VisibilityScopeChecker;
let claims = test_claims();
let resource = ResourceMeta::wiki("secret", "homelab")
.with_visibility(Visibility::Private);
let scope = AccessScope::new().with_visibility(Visibility::Private);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
// ========================================================================
// Owner Scope Tests
// ========================================================================
#[test]
fn test_owner_scope_self_owned_pass() {
let checker = OwnerScopeChecker;
let claims = test_claims(); // sub = alice
let resource = ResourceMeta::conversation("conv-1", "portfolio", "alice");
let scope = AccessScope::new().with_owner(OwnerConstraint::SelfOwned);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_owner_scope_self_owned_fail() {
let checker = OwnerScopeChecker;
let claims = test_claims(); // sub = alice
let resource = ResourceMeta::conversation("conv-1", "portfolio", "bob");
let scope = AccessScope::new().with_owner(OwnerConstraint::SelfOwned);
assert!(matches!(
checker.check(&claims, &resource, &scope),
ScopeResult::Fail(DenyReason::OwnerMismatch)
));
}
#[test]
fn test_owner_scope_any() {
let checker = OwnerScopeChecker;
let claims = test_claims();
let resource = ResourceMeta::conversation("conv-1", "portfolio", "anyone");
let scope = AccessScope::new().with_owner(OwnerConstraint::Any);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_owner_scope_specific_user() {
let checker = OwnerScopeChecker;
let claims = test_claims();
let resource = ResourceMeta::conversation("conv-1", "portfolio", "bob");
let scope = AccessScope::new().with_owner(OwnerConstraint::User("bob".into()));
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_owner_scope_no_owner_on_resource() {
let checker = OwnerScopeChecker;
let claims = test_claims();
let resource = ResourceMeta::wiki("doc-1", "homelab"); // no owner
let scope = AccessScope::new().with_owner(OwnerConstraint::SelfOwned);
assert!(matches!(
checker.check(&claims, &resource, &scope),
ScopeResult::Fail(DenyReason::OwnerMismatch)
));
}
// ========================================================================
// Group Scope Tests
// ========================================================================
#[test]
fn test_group_scope_pass() {
let checker = GroupScopeChecker;
let claims = test_claims(); // groups = ["engineering", "ml-team"]
let resource = test_resource();
let scope = AccessScope::new().with_groups(vec!["engineering".into()]);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_group_scope_any_match() {
let checker = GroupScopeChecker;
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new().with_groups(vec!["sales".into(), "ml-team".into()]);
assert_eq!(checker.check(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_group_scope_fail() {
let checker = GroupScopeChecker;
let claims = test_claims(); // groups = ["engineering", "ml-team"]
let resource = test_resource();
let scope = AccessScope::new().with_groups(vec!["sales".into(), "finance".into()]);
assert!(matches!(
checker.check(&claims, &resource, &scope),
ScopeResult::Fail(DenyReason::GroupRequired)
));
}
// ========================================================================
// Composite Scope Tests
// ========================================================================
#[test]
fn test_composite_all_pass() {
let checker = CompositeScopeChecker::new();
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new()
.with_projects(vec!["homelab".into()])
.with_visibility(Visibility::Public);
assert_eq!(checker.check_all(&claims, &resource, &scope), ScopeResult::Pass);
}
#[test]
fn test_composite_one_fail() {
let checker = CompositeScopeChecker::new();
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new()
.with_projects(vec!["homelab".into()])
.with_visibility(Visibility::Private); // mismatch
assert!(matches!(
checker.check_all(&claims, &resource, &scope),
ScopeResult::Fail(DenyReason::VisibilityMismatch)
));
}
#[test]
fn test_composite_detailed() {
let checker = CompositeScopeChecker::new();
let claims = test_claims();
let resource = test_resource();
let scope = AccessScope::new()
.with_projects(vec!["homelab".into()])
.with_visibility(Visibility::Public);
let results = checker.check_all_detailed(&claims, &resource, &scope);
assert_eq!(results.len(), 4);
assert!(results.iter().all(|(_, r)| r.is_pass()));
}
#[test]
fn test_scope_result_helpers() {
assert!(ScopeResult::Pass.is_pass());
assert!(ScopeResult::NotApplicable.is_pass());
assert!(!ScopeResult::Fail(DenyReason::VerbNotAllowed).is_pass());
assert!(ScopeResult::Fail(DenyReason::VerbNotAllowed).is_fail());
assert!(!ScopeResult::Pass.is_fail());
}
}
+654
View File
@@ -0,0 +1,654 @@
/// RBAC Types: Hierarchical access control model
///
/// Core concepts:
/// - Role: named set of AccessRules
/// - AccessRule: (resources, verbs, scope) tuple
/// - AccessScope: constraints (projects, visibility, owner)
/// - ResourceMeta: metadata attached to each document/wiki entry
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
// ============================================================================
// Verbs
// ============================================================================
/// Actions that can be performed on resources
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verb {
Read,
Write,
Delete,
Query,
}
impl Verb {
pub fn as_str(&self) -> &'static str {
match self {
Verb::Read => "read",
Verb::Write => "write",
Verb::Delete => "delete",
Verb::Query => "query",
}
}
}
impl std::fmt::Display for Verb {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
// ============================================================================
// Resource Types
// ============================================================================
/// Types of resources in the memory system
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ResourceType {
Wiki,
Conversation,
Embedding,
Project,
Skill,
}
impl ResourceType {
pub fn as_str(&self) -> &'static str {
match self {
ResourceType::Wiki => "wiki",
ResourceType::Conversation => "conversation",
ResourceType::Embedding => "embedding",
ResourceType::Project => "project",
ResourceType::Skill => "skill",
}
}
/// Parse from string, supports wildcard "*"
pub fn from_str_loose(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"wiki" => Some(ResourceType::Wiki),
"conversation" | "conversations" => Some(ResourceType::Conversation),
"embedding" | "embeddings" => Some(ResourceType::Embedding),
"project" | "projects" => Some(ResourceType::Project),
"skill" | "skills" => Some(ResourceType::Skill),
_ => None,
}
}
}
impl std::fmt::Display for ResourceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
// ============================================================================
// Visibility
// ============================================================================
/// Document visibility level
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
#[default]
Public,
Private,
}
impl Visibility {
pub fn as_str(&self) -> &'static str {
match self {
Visibility::Public => "public",
Visibility::Private => "private",
}
}
}
// ============================================================================
// Owner Constraint
// ============================================================================
/// Ownership constraint for resources (e.g., conversations)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OwnerConstraint {
/// Resource owner must match JWT subject
#[serde(rename = "self")]
SelfOwned,
/// Any owner allowed
Any,
/// Specific user ID
User(String),
}
impl Default for OwnerConstraint {
fn default() -> Self {
OwnerConstraint::Any
}
}
// ============================================================================
// Access Scope
// ============================================================================
/// Scope constraints for an access rule
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AccessScope {
/// Allowed projects (None = all, empty = none, ["*"] = all)
#[serde(default)]
pub projects: Option<Vec<String>>,
/// Required visibility (None = any)
#[serde(default)]
pub visibility: Option<Visibility>,
/// Owner constraint (None = any)
#[serde(default)]
pub owner: Option<OwnerConstraint>,
/// Required groups (user must be in at least one)
#[serde(default)]
pub groups: Option<Vec<String>>,
}
impl AccessScope {
pub fn new() -> Self {
Self::default()
}
pub fn with_projects(mut self, projects: Vec<String>) -> Self {
self.projects = Some(projects);
self
}
pub fn with_visibility(mut self, visibility: Visibility) -> Self {
self.visibility = Some(visibility);
self
}
pub fn with_owner(mut self, owner: OwnerConstraint) -> Self {
self.owner = Some(owner);
self
}
pub fn with_groups(mut self, groups: Vec<String>) -> Self {
self.groups = Some(groups);
self
}
/// Check if projects constraint allows wildcard
pub fn allows_all_projects(&self) -> bool {
match &self.projects {
None => true,
Some(projects) => projects.iter().any(|p| p == "*"),
}
}
/// Check if a specific project is allowed
pub fn allows_project(&self, project: &str) -> bool {
match &self.projects {
None => true,
Some(projects) => {
projects.iter().any(|p| p == "*" || p == project)
}
}
}
}
// ============================================================================
// Access Rule
// ============================================================================
/// A single access rule: (resources, verbs, scope)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AccessRule {
/// Resource types this rule applies to (["*"] = all)
pub resources: Vec<String>,
/// Allowed verbs
pub verbs: Vec<Verb>,
/// Scope constraints
#[serde(default)]
pub scope: AccessScope,
}
impl AccessRule {
pub fn new(resources: Vec<&str>, verbs: Vec<Verb>) -> Self {
Self {
resources: resources.into_iter().map(String::from).collect(),
verbs,
scope: AccessScope::default(),
}
}
pub fn with_scope(mut self, scope: AccessScope) -> Self {
self.scope = scope;
self
}
/// Check if this rule applies to a resource type
pub fn matches_resource(&self, resource_type: ResourceType) -> bool {
let type_str = resource_type.as_str();
self.resources.iter().any(|r| r == "*" || r == type_str)
}
/// Check if this rule allows a verb
pub fn allows_verb(&self, verb: Verb) -> bool {
self.verbs.contains(&verb)
}
/// Get parsed resource types (excluding wildcards)
pub fn resource_types(&self) -> Vec<ResourceType> {
self.resources
.iter()
.filter_map(|r| ResourceType::from_str_loose(r))
.collect()
}
}
// ============================================================================
// Role
// ============================================================================
/// A named role with access rules
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Role {
/// Role name (e.g., "portfolio-agent", "admin")
pub name: String,
/// Access rules for this role
pub rules: Vec<AccessRule>,
/// Optional description
#[serde(default)]
pub description: Option<String>,
}
impl Role {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
rules: Vec::new(),
description: None,
}
}
pub fn with_rule(mut self, rule: AccessRule) -> Self {
self.rules.push(rule);
self
}
pub fn with_rules(mut self, rules: Vec<AccessRule>) -> Self {
self.rules = rules;
self
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
/// Find all rules that match a resource type
pub fn rules_for_resource(&self, resource_type: ResourceType) -> Vec<&AccessRule> {
self.rules
.iter()
.filter(|r| r.matches_resource(resource_type))
.collect()
}
/// Check if any rule allows a verb on a resource type
pub fn allows(&self, resource_type: ResourceType, verb: Verb) -> bool {
self.rules
.iter()
.any(|r| r.matches_resource(resource_type) && r.allows_verb(verb))
}
}
// ============================================================================
// Resource Metadata
// ============================================================================
/// Metadata attached to a resource for access checks
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceMeta {
/// Unique resource identifier
pub id: String,
/// Type of resource
pub resource_type: ResourceType,
/// Project this resource belongs to
pub project: String,
/// Visibility level
#[serde(default)]
pub visibility: Visibility,
/// Owner user ID (for conversations)
#[serde(default)]
pub owner: Option<String>,
}
impl ResourceMeta {
pub fn new(id: &str, resource_type: ResourceType, project: &str) -> Self {
Self {
id: id.to_string(),
resource_type,
project: project.to_string(),
visibility: Visibility::Public,
owner: None,
}
}
pub fn with_visibility(mut self, visibility: Visibility) -> Self {
self.visibility = visibility;
self
}
pub fn with_owner(mut self, owner: &str) -> Self {
self.owner = Some(owner.to_string());
self
}
/// Create wiki entry metadata
pub fn wiki(id: &str, project: &str) -> Self {
Self::new(id, ResourceType::Wiki, project)
}
/// Create conversation metadata
pub fn conversation(id: &str, project: &str, owner: &str) -> Self {
Self::new(id, ResourceType::Conversation, project)
.with_owner(owner)
}
/// Create embedding metadata
pub fn embedding(id: &str, project: &str) -> Self {
Self::new(id, ResourceType::Embedding, project)
}
/// Create skill metadata
pub fn skill(id: &str, project: &str) -> Self {
Self::new(id, ResourceType::Skill, project)
}
}
/// Trait for types that have resource metadata
pub trait HasResourceMeta {
fn resource_meta(&self) -> &ResourceMeta;
}
impl HasResourceMeta for ResourceMeta {
fn resource_meta(&self) -> &ResourceMeta {
self
}
}
// ============================================================================
// JWT Claims (simplified for RBAC)
// ============================================================================
/// Claims extracted from JWT for access decisions
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Claims {
/// Subject (user ID)
pub sub: String,
/// Assigned roles
#[serde(default)]
pub roles: Vec<String>,
/// Group memberships
#[serde(default)]
pub groups: Vec<String>,
/// Direct permissions (capability-level)
#[serde(default)]
pub permissions: Vec<String>,
}
impl Claims {
pub fn new(sub: &str) -> Self {
Self {
sub: sub.to_string(),
roles: Vec::new(),
groups: Vec::new(),
permissions: Vec::new(),
}
}
pub fn with_roles(mut self, roles: Vec<&str>) -> Self {
self.roles = roles.into_iter().map(String::from).collect();
self
}
pub fn with_groups(mut self, groups: Vec<&str>) -> Self {
self.groups = groups.into_iter().map(String::from).collect();
self
}
pub fn with_permissions(mut self, perms: Vec<&str>) -> Self {
self.permissions = perms.into_iter().map(String::from).collect();
self
}
/// Check if user has a specific role
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
/// Check if user is in a specific group
pub fn in_group(&self, group: &str) -> bool {
self.groups.iter().any(|g| g == group)
}
/// Check if user is in any of the given groups
pub fn in_any_group(&self, groups: &[String]) -> bool {
groups.iter().any(|g| self.in_group(g))
}
/// Check if user has a specific permission
pub fn has_permission(&self, perm: &str) -> bool {
self.permissions.iter().any(|p| p == "*" || p == perm)
}
}
// ============================================================================
// Access Decision
// ============================================================================
/// Result of an access check
#[derive(Debug, Clone, PartialEq)]
pub enum AccessDecision {
Allow {
matched_role: String,
matched_rule_index: usize,
},
Deny {
reason: DenyReason,
},
}
impl AccessDecision {
pub fn is_allowed(&self) -> bool {
matches!(self, AccessDecision::Allow { .. })
}
pub fn is_denied(&self) -> bool {
matches!(self, AccessDecision::Deny { .. })
}
}
/// Reasons for access denial
#[derive(Debug, Clone, PartialEq)]
pub enum DenyReason {
NoMatchingRole,
NoMatchingRule,
VerbNotAllowed,
ProjectNotAllowed,
VisibilityMismatch,
OwnerMismatch,
GroupRequired,
PermissionRequired(String),
}
impl std::fmt::Display for DenyReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DenyReason::NoMatchingRole => write!(f, "no matching role"),
DenyReason::NoMatchingRule => write!(f, "no matching rule"),
DenyReason::VerbNotAllowed => write!(f, "verb not allowed"),
DenyReason::ProjectNotAllowed => write!(f, "project not allowed"),
DenyReason::VisibilityMismatch => write!(f, "visibility mismatch"),
DenyReason::OwnerMismatch => write!(f, "owner mismatch"),
DenyReason::GroupRequired => write!(f, "group membership required"),
DenyReason::PermissionRequired(p) => write!(f, "permission required: {}", p),
}
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verb_display() {
assert_eq!(Verb::Read.as_str(), "read");
assert_eq!(Verb::Write.as_str(), "write");
assert_eq!(Verb::Delete.as_str(), "delete");
assert_eq!(Verb::Query.as_str(), "query");
}
#[test]
fn test_resource_type_parse() {
assert_eq!(ResourceType::from_str_loose("wiki"), Some(ResourceType::Wiki));
assert_eq!(ResourceType::from_str_loose("conversations"), Some(ResourceType::Conversation));
assert_eq!(ResourceType::from_str_loose("EMBEDDING"), Some(ResourceType::Embedding));
assert_eq!(ResourceType::from_str_loose("unknown"), None);
}
#[test]
fn test_access_scope_projects() {
let scope = AccessScope::new().with_projects(vec!["homelab".into(), "rbc".into()]);
assert!(scope.allows_project("homelab"));
assert!(scope.allows_project("rbc"));
assert!(!scope.allows_project("secret"));
assert!(!scope.allows_all_projects());
let wildcard = AccessScope::new().with_projects(vec!["*".into()]);
assert!(wildcard.allows_project("anything"));
assert!(wildcard.allows_all_projects());
let empty = AccessScope::new();
assert!(empty.allows_project("any"));
assert!(empty.allows_all_projects());
}
#[test]
fn test_access_rule_matches() {
let rule = AccessRule::new(vec!["wiki", "embedding"], vec![Verb::Read, Verb::Query]);
assert!(rule.matches_resource(ResourceType::Wiki));
assert!(rule.matches_resource(ResourceType::Embedding));
assert!(!rule.matches_resource(ResourceType::Conversation));
assert!(rule.allows_verb(Verb::Read));
assert!(rule.allows_verb(Verb::Query));
assert!(!rule.allows_verb(Verb::Write));
}
#[test]
fn test_access_rule_wildcard() {
let rule = AccessRule::new(vec!["*"], vec![Verb::Read]);
assert!(rule.matches_resource(ResourceType::Wiki));
assert!(rule.matches_resource(ResourceType::Conversation));
assert!(rule.matches_resource(ResourceType::Skill));
}
#[test]
fn test_role_allows() {
let role = Role::new("reader")
.with_rule(AccessRule::new(vec!["wiki"], vec![Verb::Read]))
.with_rule(AccessRule::new(vec!["embedding"], vec![Verb::Query]));
assert!(role.allows(ResourceType::Wiki, Verb::Read));
assert!(!role.allows(ResourceType::Wiki, Verb::Write));
assert!(role.allows(ResourceType::Embedding, Verb::Query));
assert!(!role.allows(ResourceType::Conversation, Verb::Read));
}
#[test]
fn test_resource_meta_builders() {
let wiki = ResourceMeta::wiki("doc-1", "homelab")
.with_visibility(Visibility::Public);
assert_eq!(wiki.resource_type, ResourceType::Wiki);
assert_eq!(wiki.project, "homelab");
let conv = ResourceMeta::conversation("conv-1", "portfolio", "user-123");
assert_eq!(conv.resource_type, ResourceType::Conversation);
assert_eq!(conv.owner, Some("user-123".to_string()));
}
#[test]
fn test_claims_checks() {
let claims = Claims::new("alice")
.with_roles(vec!["admin", "developer"])
.with_groups(vec!["engineering", "ml-team"])
.with_permissions(vec!["memory:read", "memory:write"]);
assert!(claims.has_role("admin"));
assert!(!claims.has_role("viewer"));
assert!(claims.in_group("engineering"));
assert!(claims.in_any_group(&["sales".into(), "ml-team".into()]));
assert!(claims.has_permission("memory:read"));
}
#[test]
fn test_claims_wildcard_permission() {
let admin = Claims::new("root").with_permissions(vec!["*"]);
assert!(admin.has_permission("memory:read"));
assert!(admin.has_permission("anything:else"));
}
#[test]
fn test_access_decision() {
let allow = AccessDecision::Allow {
matched_role: "admin".into(),
matched_rule_index: 0,
};
assert!(allow.is_allowed());
assert!(!allow.is_denied());
let deny = AccessDecision::Deny {
reason: DenyReason::VerbNotAllowed,
};
assert!(deny.is_denied());
assert!(!deny.is_allowed());
}
#[test]
fn test_role_serialization() {
let role = Role::new("test")
.with_rule(AccessRule::new(vec!["wiki"], vec![Verb::Read])
.with_scope(AccessScope::new()
.with_projects(vec!["homelab".into()])
.with_visibility(Visibility::Public)));
let yaml = serde_yaml::to_string(&role).unwrap();
assert!(yaml.contains("test"));
assert!(yaml.contains("wiki"));
assert!(yaml.contains("read"));
let parsed: Role = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.name, "test");
assert_eq!(parsed.rules.len(), 1);
}
}