/// Access Checkers: Single-purpose RBAC evaluation /// /// Implements SOLID principle - Single Responsibility: /// Each checker evaluates one aspect (access level, role, permission) /// and returns true/false, with reasoning. use anyhow::Result; use async_trait::async_trait; use std::sync::Arc; use super::policy_provider::AccessPolicy; /// OIDC claims from Authentik JWT token #[derive(Debug, Clone)] pub struct OidcClaims { pub sub: String, // user ID pub groups: Vec, // group memberships pub roles: Vec, // roles: viewer, editor, admin pub permissions: Vec, // fine-grained: memory:read, skill:write } /// Single responsibility: one access check #[async_trait] pub trait AccessChecker: Send + Sync { async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result; fn description(&self) -> &str; } /// Check 1: access_level (public | group | private) pub struct AccessLevelChecker; #[async_trait] impl AccessChecker for AccessLevelChecker { async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result { match policy.access_level.as_str() { "public" => Ok(true), "private" => Ok(claims.groups.contains(&policy.owner_group)), "group" => Ok(claims .groups .iter() .any(|g| policy.allowed_groups.contains(g))), _ => Err(anyhow::anyhow!("Unknown access level: {}", policy.access_level)), } } fn description(&self) -> &str { "access_level" } } /// Check 2: role requirement (if any) pub struct RoleChecker; #[async_trait] impl AccessChecker for RoleChecker { async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result { if let Some(required_role) = &policy.required_role { Ok(claims.roles.contains(required_role)) } else { Ok(true) // No requirement } } fn description(&self) -> &str { "role" } } /// Check 3: fine-grained permission (if any) pub struct PermissionChecker; #[async_trait] impl AccessChecker for PermissionChecker { async fn check(&self, claims: &OidcClaims, policy: &AccessPolicy) -> Result { if let Some(required_perm) = &policy.required_permission { Ok(claims.permissions.contains(required_perm)) } else { Ok(true) // No requirement } } fn description(&self) -> &str { "permission" } } /// Audit log entry #[derive(Debug, Clone)] pub struct AccessDecision { pub user_id: String, pub resource_type: String, pub resource_name: String, pub decision: String, // "allow" | "deny" pub reason: String, // checker name or error } /// Pluggable audit logger #[async_trait] pub trait AuditLogger: Send + Sync { async fn log_decision(&self, decision: AccessDecision) -> Result<()>; } /// No-op audit logger (for testing) pub struct NoOpAuditLogger; #[async_trait] impl AuditLogger for NoOpAuditLogger { async fn log_decision(&self, _decision: AccessDecision) -> Result<()> { Ok(()) } } /// Access Decision Engine: Orchestrates all checkers pub struct AccessDecisionEngine { checkers: Vec>, policy_provider: Arc, audit: Arc, } impl AccessDecisionEngine { pub fn new( policy_provider: Arc, audit: Arc, ) -> Self { Self { checkers: vec![ Arc::new(AccessLevelChecker), Arc::new(RoleChecker), Arc::new(PermissionChecker), ], policy_provider, audit, } } /// Central authorization decision point pub async fn check_access( &self, claims: &OidcClaims, resource_type: &str, resource_name: &str, ) -> Result { // Load policy let policy = self .policy_provider .get_policy(resource_type, resource_name) .await?; // Evaluate all checkers (short-circuit on failure) let mut allowed = true; let mut reason = String::new(); for checker in &self.checkers { match checker.check(claims, &policy).await { Ok(true) => {} Ok(false) => { allowed = false; reason = checker.description().to_string(); break; } Err(e) => return Err(e), } } // Audit log (always) self.audit .log_decision(AccessDecision { user_id: claims.sub.clone(), resource_type: resource_type.to_string(), resource_name: resource_name.to_string(), decision: if allowed { "allow" } else { "deny" }.to_string(), reason, }) .await?; Ok(allowed) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_access_level_public() { let checker = AccessLevelChecker; let claims = OidcClaims { sub: "anyone".to_string(), groups: vec![], roles: vec![], permissions: vec![], }; let policy = AccessPolicy { access_level: "public".to_string(), owner_group: "".to_string(), allowed_groups: vec![], required_role: None, required_permission: None, }; let allowed = checker.check(&claims, &policy).await.unwrap(); assert!(allowed); } #[tokio::test] async fn test_access_level_private() { let checker = AccessLevelChecker; let claims = OidcClaims { sub: "charlie".to_string(), groups: vec!["platform-team".to_string()], roles: vec![], permissions: vec![], }; let policy = AccessPolicy { access_level: "private".to_string(), owner_group: "platform-team".to_string(), allowed_groups: vec![], required_role: None, required_permission: None, }; let allowed = checker.check(&claims, &policy).await.unwrap(); assert!(allowed); } #[tokio::test] async fn test_access_level_private_denied() { let checker = AccessLevelChecker; let claims = OidcClaims { sub: "alice".to_string(), groups: vec!["data-team".to_string()], roles: vec![], permissions: vec![], }; let policy = AccessPolicy { access_level: "private".to_string(), owner_group: "platform-team".to_string(), allowed_groups: vec![], required_role: None, required_permission: None, }; let allowed = checker.check(&claims, &policy).await.unwrap(); assert!(!allowed); } #[tokio::test] async fn test_access_level_group() { let checker = AccessLevelChecker; let claims = OidcClaims { sub: "charlie".to_string(), groups: vec!["devops-team".to_string()], roles: vec![], permissions: vec![], }; let policy = AccessPolicy { access_level: "group".to_string(), owner_group: "".to_string(), allowed_groups: vec!["platform-team".to_string(), "devops-team".to_string()], required_role: None, required_permission: None, }; let allowed = checker.check(&claims, &policy).await.unwrap(); assert!(allowed); } #[tokio::test] async fn test_role_checker_required() { let checker = RoleChecker; let claims = OidcClaims { sub: "charlie".to_string(), groups: vec![], roles: vec!["viewer".to_string()], permissions: vec![], }; let policy = AccessPolicy { access_level: "public".to_string(), owner_group: "".to_string(), allowed_groups: vec![], required_role: Some("viewer".to_string()), required_permission: None, }; let allowed = checker.check(&claims, &policy).await.unwrap(); assert!(allowed); } #[tokio::test] async fn test_permission_checker_required() { let checker = PermissionChecker; let claims = OidcClaims { sub: "charlie".to_string(), groups: vec![], roles: vec![], permissions: vec!["skill:read".to_string()], }; let policy = AccessPolicy { access_level: "public".to_string(), owner_group: "".to_string(), allowed_groups: vec![], required_role: None, required_permission: Some("skill:read".to_string()), }; let allowed = checker.check(&claims, &policy).await.unwrap(); assert!(allowed); } }