use crate::error::{DbError, DbResult};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PermissionAction {
Admin,
Write,
Read,
}
impl PermissionAction {
pub fn implies(&self, other: &PermissionAction) -> bool {
match self {
PermissionAction::Admin => true, PermissionAction::Write => {
matches!(other, PermissionAction::Write | PermissionAction::Read)
}
PermissionAction::Read => matches!(other, PermissionAction::Read),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PermissionScope {
Global,
Database,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Permission {
pub action: PermissionAction,
pub scope: PermissionScope,
#[serde(skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
}
impl Permission {
pub fn global_admin() -> Self {
Self {
action: PermissionAction::Admin,
scope: PermissionScope::Global,
database: None,
}
}
pub fn global_write() -> Self {
Self {
action: PermissionAction::Write,
scope: PermissionScope::Global,
database: None,
}
}
pub fn global_read() -> Self {
Self {
action: PermissionAction::Read,
scope: PermissionScope::Global,
database: None,
}
}
pub fn database_permission(action: PermissionAction, database: &str) -> Self {
Self {
action,
scope: PermissionScope::Database,
database: Some(database.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Role {
#[serde(rename = "_key")]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub permissions: Vec<Permission>,
#[serde(default)]
pub is_builtin: bool,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl Role {
pub fn builtin_admin() -> Self {
Self {
name: "admin".to_string(),
description: Some("Full system access".to_string()),
permissions: vec![Permission::global_admin()],
is_builtin: true,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: None,
}
}
pub fn builtin_editor() -> Self {
Self {
name: "editor".to_string(),
description: Some("Read and write access to all databases".to_string()),
permissions: vec![Permission::global_write(), Permission::global_read()],
is_builtin: true,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: None,
}
}
pub fn builtin_viewer() -> Self {
Self {
name: "viewer".to_string(),
description: Some("Read-only access to all databases".to_string()),
permissions: vec![Permission::global_read()],
is_builtin: true,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: None,
}
}
pub fn builtin_roles() -> Vec<Self> {
vec![
Self::builtin_admin(),
Self::builtin_editor(),
Self::builtin_viewer(),
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserRole {
#[serde(rename = "_key")]
pub id: String,
pub username: String,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
pub assigned_at: String,
pub assigned_by: String,
}
impl UserRole {
pub fn new_global(username: &str, role: &str, assigned_by: &str) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
username: username.to_string(),
role: role.to_string(),
database: None,
assigned_at: chrono::Utc::now().to_rfc3339(),
assigned_by: assigned_by.to_string(),
}
}
pub fn new_database_scoped(
username: &str,
role: &str,
database: &str,
assigned_by: &str,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
username: username.to_string(),
role: role.to_string(),
database: Some(database.to_string()),
assigned_at: chrono::Utc::now().to_rfc3339(),
assigned_by: assigned_by.to_string(),
}
}
}
pub const ROLES_COLLECTION: &str = "_roles";
pub const USER_ROLES_COLLECTION: &str = "_user_roles";
pub struct AuthorizationService;
impl AuthorizationService {
fn has_permission(permissions: &HashSet<Permission>, required: &Permission) -> bool {
for perm in permissions {
if perm == required {
return true;
}
if !perm.action.implies(&required.action) {
continue;
}
if perm.scope == PermissionScope::Global {
return true;
}
if perm.scope == PermissionScope::Database
&& required.scope == PermissionScope::Database
&& perm.database == required.database
{
return true;
}
}
false
}
pub fn resolve_permissions(roles: &[Role]) -> HashSet<Permission> {
let mut permissions = HashSet::new();
for role in roles {
for perm in &role.permissions {
permissions.insert(perm.clone());
}
}
permissions
}
pub async fn get_effective_permissions(
claims: &crate::server::auth::Claims,
state: &crate::server::handlers::AppState,
) -> DbResult<HashSet<Permission>> {
use crate::server::permission_cache::CachedPermissions;
if let Some(cached) = state.permission_cache.get(&claims.sub) {
return Ok(cached.permissions);
}
let role_names = claims.roles.clone().unwrap_or_default();
if role_names.is_empty() {
return Ok(HashSet::new());
}
let mut roles = Vec::new();
let roles_coll = state
.storage
.get_database("_system")
.ok()
.and_then(|db| db.get_collection(ROLES_COLLECTION).ok());
for role_name in &role_names {
if let Some(role) = state.permission_cache.get_role(role_name) {
roles.push(role);
} else if let Some(ref coll) = roles_coll {
if let Ok(doc) = coll.get(role_name) {
if let Ok(role) = serde_json::from_value::<Role>(doc.data) {
state.permission_cache.set_role(role.clone());
roles.push(role);
}
}
}
}
let permissions = Self::resolve_permissions(&roles);
let cached = CachedPermissions::new(
permissions.clone(),
role_names,
claims.scoped_databases.clone(),
);
state.permission_cache.set(claims.sub.clone(), cached);
Ok(permissions)
}
pub fn load_permissions_from_storage(
storage: &crate::storage::StorageEngine,
role_names: &[String],
) -> HashSet<Permission> {
let mut roles = Vec::new();
let roles_coll = storage
.get_database("_system")
.ok()
.and_then(|db| db.get_collection(ROLES_COLLECTION).ok());
for role_name in role_names {
let stored = roles_coll
.as_ref()
.and_then(|coll| coll.get(role_name).ok())
.and_then(|doc| serde_json::from_value::<Role>(doc.data).ok());
if let Some(role) = stored {
roles.push(role);
} else if let Some(builtin) = Role::builtin_roles()
.into_iter()
.find(|r| &r.name == role_name)
{
roles.push(builtin);
}
}
Self::resolve_permissions(&roles)
}
pub async fn check_permission(
claims: &crate::server::auth::Claims,
state: &crate::server::handlers::AppState,
required_action: PermissionAction,
database: Option<&str>,
) -> DbResult<()> {
let permissions = Self::get_effective_permissions(claims, state).await?;
let scoped_databases = claims.scoped_databases.as_deref();
Self::check_permission_raw(&permissions, required_action, database, scoped_databases)
}
pub fn check_permission_raw(
permissions: &HashSet<Permission>,
required_action: PermissionAction,
database: Option<&str>,
scoped_databases: Option<&[String]>,
) -> DbResult<()> {
if let Some(scoped_dbs) = scoped_databases {
match database {
Some(db) if scoped_dbs.iter().any(|d| d == db) => {}
Some(db) => {
return Err(DbError::Forbidden(format!(
"Access denied: API key not authorized for database '{}'",
db
)));
}
None => {
return Err(DbError::Forbidden(
"Access denied: database-scoped API key cannot perform global operations"
.to_string(),
));
}
}
}
if permissions.contains(&Permission::global_admin()) {
return Ok(());
}
let required = Permission {
action: required_action.clone(),
scope: if database.is_some() {
PermissionScope::Database
} else {
PermissionScope::Global
},
database: database.map(String::from),
};
if Self::has_permission(permissions, &required) {
Ok(())
} else {
Err(DbError::Forbidden(format!(
"Access denied: insufficient permissions for {:?} on {}",
required_action,
database.unwrap_or("global")
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permission_action_implies() {
assert!(PermissionAction::Admin.implies(&PermissionAction::Admin));
assert!(PermissionAction::Admin.implies(&PermissionAction::Write));
assert!(PermissionAction::Admin.implies(&PermissionAction::Read));
assert!(!PermissionAction::Write.implies(&PermissionAction::Admin));
assert!(PermissionAction::Write.implies(&PermissionAction::Write));
assert!(PermissionAction::Write.implies(&PermissionAction::Read));
assert!(!PermissionAction::Read.implies(&PermissionAction::Admin));
assert!(!PermissionAction::Read.implies(&PermissionAction::Write));
assert!(PermissionAction::Read.implies(&PermissionAction::Read));
}
#[test]
fn test_global_admin_implies_all() {
let mut permissions = HashSet::new();
permissions.insert(Permission::global_admin());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Admin,
None,
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
None,
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Read,
None,
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
Some("mydb"),
None
)
.is_ok());
}
#[test]
fn test_global_write_implies_read() {
let mut permissions = HashSet::new();
permissions.insert(Permission::global_write());
permissions.insert(Permission::global_read());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Read,
None,
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
None,
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Admin,
None,
None
)
.is_err());
}
#[test]
fn test_database_scope_restriction() {
let mut permissions = HashSet::new();
permissions.insert(Permission::database_permission(
PermissionAction::Write,
"allowed_db",
));
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
Some("allowed_db"),
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Read,
Some("allowed_db"),
None
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
Some("other_db"),
None
)
.is_err());
}
#[test]
fn test_api_key_scoped_databases() {
let mut permissions = HashSet::new();
permissions.insert(Permission::global_write());
permissions.insert(Permission::global_read());
let scoped_dbs = vec!["db1".to_string(), "db2".to_string()];
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
Some("db1"),
Some(&scoped_dbs)
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
Some("db2"),
Some(&scoped_dbs)
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Write,
Some("db3"),
Some(&scoped_dbs)
)
.is_err());
}
#[test]
fn test_scoped_key_denied_global_operations() {
let mut permissions = HashSet::new();
permissions.insert(Permission::global_admin());
let scoped_dbs = vec!["db1".to_string()];
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Admin,
None,
Some(&scoped_dbs)
)
.is_err());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Read,
None,
Some(&scoped_dbs)
)
.is_err());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Admin,
Some("db1"),
Some(&scoped_dbs)
)
.is_ok());
assert!(AuthorizationService::check_permission_raw(
&permissions,
PermissionAction::Admin,
None,
None
)
.is_ok());
}
#[test]
fn test_builtin_roles() {
let roles = Role::builtin_roles();
assert_eq!(roles.len(), 3);
let admin = &roles[0];
assert_eq!(admin.name, "admin");
assert!(admin.is_builtin);
assert!(admin.permissions.contains(&Permission::global_admin()));
let editor = &roles[1];
assert_eq!(editor.name, "editor");
assert!(editor.permissions.contains(&Permission::global_write()));
assert!(editor.permissions.contains(&Permission::global_read()));
let viewer = &roles[2];
assert_eq!(viewer.name, "viewer");
assert!(viewer.permissions.contains(&Permission::global_read()));
}
#[test]
fn test_resolve_permissions() {
let roles = vec![Role::builtin_editor(), Role::builtin_viewer()];
let permissions = AuthorizationService::resolve_permissions(&roles);
assert!(permissions.contains(&Permission::global_write()));
assert!(permissions.contains(&Permission::global_read()));
assert!(!permissions.contains(&Permission::global_admin()));
}
}