use crate::auth::Principal;
use crate::config::AuthorizationSettings;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use thiserror::Error;
use tracing::{debug, info};
#[derive(Error, Debug)]
pub enum AuthzError {
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Role not found: {0}")]
RoleNotFound(String),
#[error("Invalid permission: {0}")]
InvalidPermission(String),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("TOML error: {0}")]
Toml(#[from] toml::de::Error),
}
pub type AuthzResult<T> = Result<T, AuthzError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Hash)]
pub enum Permission {
Read,
Write,
Admin,
}
impl Permission {
pub fn includes(&self, other: Permission) -> bool {
matches!(
(self, other),
(Permission::Admin, _)
| (Permission::Write, Permission::Read)
| (Permission::Write, Permission::Write)
| (Permission::Read, Permission::Read)
)
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"read" => Some(Permission::Read),
"write" => Some(Permission::Write),
"admin" => Some(Permission::Admin),
_ => None,
}
}
}
impl std::str::FromStr for Permission {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Permission::parse(s).ok_or(())
}
}
impl std::fmt::Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Permission::Read => write!(f, "read"),
Permission::Write => write!(f, "write"),
Permission::Admin => write!(f, "admin"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Read,
Write,
Delete,
CreateCollection,
DropCollection,
ListCollections,
Admin,
}
impl Action {
pub fn required_permission(&self) -> Permission {
match self {
Action::Read | Action::ListCollections => Permission::Read,
Action::Write | Action::Delete => Permission::Write,
Action::CreateCollection | Action::DropCollection | Action::Admin => Permission::Admin,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resource {
Collection(String),
AllCollections,
Server,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Role {
pub name: String,
pub description: String,
pub permissions: Vec<PermissionRule>,
#[serde(default)]
pub inherits: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub resource: String,
pub actions: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PolicyFile {
roles: Vec<Role>,
}
pub struct Authorizer {
config: Arc<AuthorizationSettings>,
roles: HashMap<String, Role>,
default_role: String,
deny_by_default: bool,
}
impl Authorizer {
pub fn new(config: AuthorizationSettings) -> AuthzResult<Self> {
let config = Arc::new(config);
let roles = if let Some(ref roles_file) = config.roles_file {
Self::load_roles(roles_file)?
} else {
Self::default_roles()
};
let deny_by_default = config.default_mode == "deny-by-default";
Ok(Self {
default_role: config.default_role.clone(),
config,
roles,
deny_by_default,
})
}
fn load_roles(path: &Path) -> AuthzResult<HashMap<String, Role>> {
if !path.exists() {
return Err(AuthzError::ConfigError(format!(
"Roles file does not exist: {}",
path.display()
)));
}
let contents = fs::read_to_string(path)?;
let policy: PolicyFile = if path.extension().and_then(|s| s.to_str()) == Some("json") {
serde_json::from_str(&contents)?
} else {
toml::from_str(&contents)?
};
let mut roles = HashMap::new();
for role in policy.roles {
roles.insert(role.name.clone(), role);
}
info!("Loaded {} roles from {}", roles.len(), path.display());
Ok(roles)
}
fn default_roles() -> HashMap<String, Role> {
let mut roles = HashMap::new();
roles.insert(
"admin".to_string(),
Role {
name: "admin".to_string(),
description: "Administrator with full access".to_string(),
permissions: vec![PermissionRule {
resource: "*".to_string(),
actions: vec!["admin".to_string()],
}],
inherits: Vec::new(),
},
);
roles.insert(
"user".to_string(),
Role {
name: "user".to_string(),
description: "Regular user with read/write access".to_string(),
permissions: vec![PermissionRule {
resource: "collection:*".to_string(),
actions: vec!["read".to_string(), "write".to_string()],
}],
inherits: Vec::new(),
},
);
roles.insert(
"reader".to_string(),
Role {
name: "reader".to_string(),
description: "Read-only user".to_string(),
permissions: vec![PermissionRule {
resource: "collection:*".to_string(),
actions: vec!["read".to_string()],
}],
inherits: Vec::new(),
},
);
info!("Using default built-in roles");
roles
}
pub fn authorize(
&self,
principal: &Principal,
action: &Action,
resource: &Resource,
) -> AuthzResult<()> {
if !self.config.enabled {
return Ok(());
}
let role_name = principal
.get_attribute("role")
.map(|s| s.as_str())
.unwrap_or(&self.default_role);
if self.has_permission(role_name, action, resource)? {
debug!(
"Authorized: user={} role={} action={:?} resource={:?}",
principal.name, role_name, action, resource
);
Ok(())
} else {
Err(AuthzError::PermissionDenied(format!(
"User '{}' with role '{}' not authorized to {:?} on {:?}",
principal.name, role_name, action, resource
)))
}
}
fn has_permission(
&self,
role_name: &str,
action: &Action,
resource: &Resource,
) -> AuthzResult<bool> {
let role = self
.roles
.get(role_name)
.ok_or_else(|| AuthzError::RoleNotFound(role_name.to_string()))?;
let permissions = self.collect_permissions(role)?;
let required_permission = action.required_permission();
for rule in &permissions {
if self.matches_resource(&rule.resource, resource) {
for action_str in &rule.actions {
if let Some(granted_permission) = Permission::parse(action_str) {
if granted_permission.includes(required_permission) {
return Ok(true);
}
}
}
}
}
Ok(!self.deny_by_default)
}
fn collect_permissions(&self, role: &Role) -> AuthzResult<Vec<PermissionRule>> {
let mut permissions = role.permissions.clone();
let mut visited = HashSet::new();
visited.insert(role.name.clone());
for parent_name in &role.inherits {
if visited.contains(parent_name) {
continue;
}
let parent = self.roles.get(parent_name).ok_or_else(|| {
AuthzError::ConfigError(format!("Parent role '{}' not found", parent_name))
})?;
let parent_permissions = self.collect_permissions(parent)?;
permissions.extend(parent_permissions);
visited.insert(parent_name.clone());
}
Ok(permissions)
}
fn matches_resource(&self, pattern: &str, resource: &Resource) -> bool {
match (pattern, resource) {
("*", _) => true,
("collection:*", Resource::Collection(_)) => true,
("collection:*", Resource::AllCollections) => true,
(p, Resource::Collection(name)) if p.starts_with("collection:") => {
let pattern_name = &p["collection:".len()..];
pattern_name == name || pattern_name == "*"
}
("server", Resource::Server) => true,
_ => false,
}
}
pub fn get_role(&self, role_name: &str) -> Option<&Role> {
self.roles.get(role_name)
}
pub fn list_roles(&self) -> Vec<&Role> {
self.roles.values().collect()
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthMethod;
#[test]
fn test_permission_includes() {
assert!(Permission::Admin.includes(Permission::Read));
assert!(Permission::Admin.includes(Permission::Write));
assert!(Permission::Admin.includes(Permission::Admin));
assert!(Permission::Write.includes(Permission::Read));
assert!(Permission::Write.includes(Permission::Write));
assert!(!Permission::Write.includes(Permission::Admin));
assert!(Permission::Read.includes(Permission::Read));
assert!(!Permission::Read.includes(Permission::Write));
assert!(!Permission::Read.includes(Permission::Admin));
}
#[test]
fn test_permission_from_str() {
assert_eq!(Permission::parse("read"), Some(Permission::Read));
assert_eq!(Permission::parse("write"), Some(Permission::Write));
assert_eq!(Permission::parse("admin"), Some(Permission::Admin));
assert_eq!(Permission::parse("invalid"), None);
}
#[test]
fn test_action_required_permission() {
assert_eq!(Action::Read.required_permission(), Permission::Read);
assert_eq!(Action::Write.required_permission(), Permission::Write);
assert_eq!(Action::Delete.required_permission(), Permission::Write);
assert_eq!(
Action::CreateCollection.required_permission(),
Permission::Admin
);
assert_eq!(Action::Admin.required_permission(), Permission::Admin);
}
#[test]
fn test_authorizer_creation() {
let config = AuthorizationSettings {
enabled: true,
default_role: "user".to_string(),
roles_file: None,
policies_file: None,
collection_permissions: true,
default_mode: "deny-by-default".to_string(),
audit_enabled: true,
audit_log_path: None,
};
let authz = Authorizer::new(config).expect("Failed to create authorizer");
assert!(authz.is_enabled());
assert_eq!(authz.list_roles().len(), 3); }
#[test]
fn test_admin_role_authorization() {
let config = AuthorizationSettings {
enabled: true,
default_role: "user".to_string(),
roles_file: None,
policies_file: None,
collection_permissions: true,
default_mode: "deny-by-default".to_string(),
audit_enabled: true,
audit_log_path: None,
};
let authz = Authorizer::new(config).expect("Failed to create authorizer");
let principal = Principal::new(
"admin1".to_string(),
"Admin User".to_string(),
AuthMethod::Jwt,
)
.with_attribute("role".to_string(), "admin".to_string());
assert!(
authz
.authorize(
&principal,
&Action::Read,
&Resource::Collection("test".to_string())
)
.is_ok()
);
assert!(
authz
.authorize(
&principal,
&Action::Write,
&Resource::Collection("test".to_string())
)
.is_ok()
);
assert!(
authz
.authorize(&principal, &Action::CreateCollection, &Resource::Server)
.is_ok()
);
}
#[test]
fn test_user_role_authorization() {
let config = AuthorizationSettings {
enabled: true,
default_role: "user".to_string(),
roles_file: None,
policies_file: None,
collection_permissions: true,
default_mode: "deny-by-default".to_string(),
audit_enabled: true,
audit_log_path: None,
};
let authz = Authorizer::new(config).expect("Failed to create authorizer");
let principal = Principal::new(
"user1".to_string(),
"Regular User".to_string(),
AuthMethod::Jwt,
)
.with_attribute("role".to_string(), "user".to_string());
assert!(
authz
.authorize(
&principal,
&Action::Read,
&Resource::Collection("test".to_string())
)
.is_ok()
);
assert!(
authz
.authorize(
&principal,
&Action::Write,
&Resource::Collection("test".to_string())
)
.is_ok()
);
assert!(
authz
.authorize(&principal, &Action::CreateCollection, &Resource::Server)
.is_err()
);
}
#[test]
fn test_reader_role_authorization() {
let config = AuthorizationSettings {
enabled: true,
default_role: "user".to_string(),
roles_file: None,
policies_file: None,
collection_permissions: true,
default_mode: "deny-by-default".to_string(),
audit_enabled: true,
audit_log_path: None,
};
let authz = Authorizer::new(config).expect("Failed to create authorizer");
let principal = Principal::new(
"reader1".to_string(),
"Read User".to_string(),
AuthMethod::Jwt,
)
.with_attribute("role".to_string(), "reader".to_string());
assert!(
authz
.authorize(
&principal,
&Action::Read,
&Resource::Collection("test".to_string())
)
.is_ok()
);
assert!(
authz
.authorize(
&principal,
&Action::Write,
&Resource::Collection("test".to_string())
)
.is_err()
);
}
#[test]
fn test_authorization_disabled() {
let config = AuthorizationSettings {
enabled: false,
default_role: "user".to_string(),
roles_file: None,
policies_file: None,
collection_permissions: true,
default_mode: "deny-by-default".to_string(),
audit_enabled: true,
audit_log_path: None,
};
let authz = Authorizer::new(config).expect("Failed to create authorizer");
let principal = Principal::new(
"user1".to_string(),
"Test User".to_string(),
AuthMethod::Jwt,
);
assert!(
authz
.authorize(&principal, &Action::Admin, &Resource::Server)
.is_ok()
);
}
#[test]
fn test_resource_matching() {
let config = AuthorizationSettings {
enabled: true,
default_role: "user".to_string(),
roles_file: None,
policies_file: None,
collection_permissions: true,
default_mode: "deny-by-default".to_string(),
audit_enabled: true,
audit_log_path: None,
};
let authz = Authorizer::new(config).expect("Failed to create authorizer");
assert!(authz.matches_resource("*", &Resource::Collection("test".to_string())));
assert!(authz.matches_resource("*", &Resource::Server));
assert!(authz.matches_resource("collection:*", &Resource::Collection("test".to_string())));
assert!(!authz.matches_resource("collection:*", &Resource::Server));
assert!(
authz.matches_resource("collection:test", &Resource::Collection("test".to_string()))
);
assert!(!authz.matches_resource(
"collection:test",
&Resource::Collection("other".to_string())
));
}
}