use crate::errors::AppError;
use crate::repositories::{MembershipRepository, OrgRepository, OrgRole, UserRepository};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Permission {
OrgRead,
OrgUpdate,
OrgDelete,
MemberRead,
MemberInvite,
MemberRemove,
MemberRoleChange,
InviteRead,
InviteCreate,
InviteCancel,
AuditRead,
}
impl Permission {
pub fn as_str(&self) -> &'static str {
match self {
Permission::OrgRead => "org:read",
Permission::OrgUpdate => "org:update",
Permission::OrgDelete => "org:delete",
Permission::MemberRead => "member:read",
Permission::MemberInvite => "member:invite",
Permission::MemberRemove => "member:remove",
Permission::MemberRoleChange => "member:role_change",
Permission::InviteRead => "invite:read",
Permission::InviteCreate => "invite:create",
Permission::InviteCancel => "invite:cancel",
Permission::AuditRead => "audit:read",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Permission> {
match s {
"org:read" => Some(Permission::OrgRead),
"org:update" => Some(Permission::OrgUpdate),
"org:delete" => Some(Permission::OrgDelete),
"member:read" => Some(Permission::MemberRead),
"member:invite" => Some(Permission::MemberInvite),
"member:remove" => Some(Permission::MemberRemove),
"member:role_change" => Some(Permission::MemberRoleChange),
"invite:read" => Some(Permission::InviteRead),
"invite:create" => Some(Permission::InviteCreate),
"invite:cancel" => Some(Permission::InviteCancel),
"audit:read" => Some(Permission::AuditRead),
_ => None,
}
}
pub fn for_role(role: OrgRole) -> Vec<Permission> {
match role {
OrgRole::Owner => vec![
Permission::OrgRead,
Permission::OrgUpdate,
Permission::OrgDelete,
Permission::MemberRead,
Permission::MemberInvite,
Permission::MemberRemove,
Permission::MemberRoleChange,
Permission::InviteRead,
Permission::InviteCreate,
Permission::InviteCancel,
Permission::AuditRead,
],
OrgRole::Admin => vec![
Permission::OrgRead,
Permission::OrgUpdate,
Permission::MemberRead,
Permission::MemberInvite,
Permission::MemberRemove,
Permission::MemberRoleChange,
Permission::InviteRead,
Permission::InviteCreate,
Permission::InviteCancel,
Permission::AuditRead,
],
OrgRole::Member => vec![
Permission::OrgRead,
Permission::MemberRead,
Permission::InviteRead,
],
}
}
pub fn is_allowed_for(&self, role: OrgRole) -> bool {
match role {
OrgRole::Owner => true,
OrgRole::Admin => !matches!(self, Permission::OrgDelete),
OrgRole::Member => matches!(
self,
Permission::OrgRead | Permission::MemberRead | Permission::InviteRead
),
}
}
}
#[derive(Debug, Clone)]
pub struct AuthContext {
pub user_id: Uuid,
pub org_id: Option<Uuid>,
pub role: Option<OrgRole>,
pub is_system_admin: bool,
}
#[derive(Debug, Clone)]
struct CachedAuthContext {
user_exists: bool,
is_system_admin: bool,
org_exists: bool,
membership_role: Option<OrgRole>,
}
#[derive(Debug, Clone)]
pub struct AuthorizationResult {
pub allowed: bool,
pub reason: Option<String>,
}
impl AuthorizationResult {
pub fn allowed() -> Self {
Self {
allowed: true,
reason: None,
}
}
pub fn denied(reason: impl Into<String>) -> Self {
Self {
allowed: false,
reason: Some(reason.into()),
}
}
}
pub struct AuthorizationService {
user_repo: Arc<dyn UserRepository>,
org_repo: Arc<dyn OrgRepository>,
membership_repo: Arc<dyn MembershipRepository>,
}
impl AuthorizationService {
pub fn new(
user_repo: Arc<dyn UserRepository>,
org_repo: Arc<dyn OrgRepository>,
membership_repo: Arc<dyn MembershipRepository>,
) -> Self {
Self {
user_repo,
org_repo,
membership_repo,
}
}
pub async fn check_permission(
&self,
user_id: Uuid,
org_id: Uuid,
permission: Permission,
) -> Result<AuthorizationResult, AppError> {
let ctx = self.fetch_auth_context(user_id, org_id).await?;
Ok(self.check_permission_with_context(&ctx, permission))
}
async fn fetch_auth_context(
&self,
user_id: Uuid,
org_id: Uuid,
) -> Result<CachedAuthContext, AppError> {
let user = self.user_repo.find_by_id(user_id).await?;
let (user_exists, is_system_admin) = match user {
Some(u) => (true, u.is_system_admin),
None => (false, false),
};
if !user_exists {
return Ok(CachedAuthContext {
user_exists: false,
is_system_admin: false,
org_exists: false,
membership_role: None,
});
}
let org = self.org_repo.find_by_id(org_id).await?;
if org.is_none() {
return Ok(CachedAuthContext {
user_exists: true,
is_system_admin,
org_exists: false,
membership_role: None,
});
}
let membership = self
.membership_repo
.find_by_user_and_org(user_id, org_id)
.await?;
Ok(CachedAuthContext {
user_exists: true,
is_system_admin,
org_exists: true,
membership_role: membership.map(|m| m.role),
})
}
fn check_permission_with_context(
&self,
ctx: &CachedAuthContext,
permission: Permission,
) -> AuthorizationResult {
if !ctx.user_exists {
return AuthorizationResult::denied("User not found");
}
if ctx.is_system_admin {
return AuthorizationResult::allowed();
}
if !ctx.org_exists {
return AuthorizationResult::denied("Organization not found");
}
match ctx.membership_role {
Some(role) => {
if permission.is_allowed_for(role) {
AuthorizationResult::allowed()
} else {
AuthorizationResult::denied(format!(
"Role '{}' does not have '{}' permission",
role.as_str(),
permission.as_str()
))
}
}
None => AuthorizationResult::denied("Not a member of this organization"),
}
}
pub async fn check_any_permission(
&self,
user_id: Uuid,
org_id: Uuid,
permissions: &[Permission],
) -> Result<AuthorizationResult, AppError> {
let ctx = self.fetch_auth_context(user_id, org_id).await?;
for permission in permissions {
let result = self.check_permission_with_context(&ctx, *permission);
if result.allowed {
return Ok(result);
}
}
Ok(AuthorizationResult::denied(
"None of the required permissions are granted",
))
}
pub async fn check_all_permissions(
&self,
user_id: Uuid,
org_id: Uuid,
permissions: &[Permission],
) -> Result<AuthorizationResult, AppError> {
let ctx = self.fetch_auth_context(user_id, org_id).await?;
for permission in permissions {
let result = self.check_permission_with_context(&ctx, *permission);
if !result.allowed {
return Ok(result);
}
}
Ok(AuthorizationResult::allowed())
}
pub async fn get_user_permissions(
&self,
user_id: Uuid,
org_id: Uuid,
) -> Result<Vec<Permission>, AppError> {
let user = self.user_repo.find_by_id(user_id).await?;
if let Some(user) = user {
if user.is_system_admin {
return Ok(Permission::for_role(OrgRole::Owner));
}
} else {
return Ok(vec![]);
}
let membership = self
.membership_repo
.find_by_user_and_org(user_id, org_id)
.await?;
match membership {
Some(m) => Ok(Permission::for_role(m.role)),
None => Ok(vec![]),
}
}
pub async fn build_context(
&self,
user_id: Uuid,
org_id: Option<Uuid>,
) -> Result<AuthContext, AppError> {
let user = self
.user_repo
.find_by_id(user_id)
.await?
.ok_or(AppError::NotFound("User not found".into()))?;
let (org_id, role) = if let Some(oid) = org_id {
let membership = self
.membership_repo
.find_by_user_and_org(user_id, oid)
.await?;
(Some(oid), membership.map(|m| m.role))
} else {
(None, None)
};
Ok(AuthContext {
user_id,
org_id,
role,
is_system_admin: user.is_system_admin,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permission_for_role() {
let owner_perms = Permission::for_role(OrgRole::Owner);
assert!(owner_perms.contains(&Permission::OrgDelete));
assert!(owner_perms.contains(&Permission::MemberRoleChange));
let admin_perms = Permission::for_role(OrgRole::Admin);
assert!(!admin_perms.contains(&Permission::OrgDelete));
assert!(admin_perms.contains(&Permission::MemberInvite));
let member_perms = Permission::for_role(OrgRole::Member);
assert!(!member_perms.contains(&Permission::MemberInvite));
assert!(member_perms.contains(&Permission::OrgRead));
}
#[test]
fn test_permission_is_allowed_for() {
assert!(Permission::OrgDelete.is_allowed_for(OrgRole::Owner));
assert!(!Permission::OrgDelete.is_allowed_for(OrgRole::Admin));
assert!(!Permission::OrgDelete.is_allowed_for(OrgRole::Member));
assert!(Permission::MemberInvite.is_allowed_for(OrgRole::Owner));
assert!(Permission::MemberInvite.is_allowed_for(OrgRole::Admin));
assert!(!Permission::MemberInvite.is_allowed_for(OrgRole::Member));
}
#[test]
fn test_permission_string_conversion() {
assert_eq!(Permission::OrgRead.as_str(), "org:read");
assert_eq!(Permission::from_str("org:read"), Some(Permission::OrgRead));
assert_eq!(Permission::from_str("invalid"), None);
}
}