feat: add multi-user support and role-based access control #31

This commit is contained in:
rustmailer
2025-12-26 14:27:04 +08:00
parent 1e2f526a07
commit 4af5176b65
181 changed files with 23745 additions and 3187 deletions
+73
View File
@@ -0,0 +1,73 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{collections::BTreeSet, net::IpAddr};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{modules::error::{BichonResult, code::ErrorCode}, raise_error};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
+32
View File
@@ -0,0 +1,32 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{
context::Initialize,
error::BichonResult,
users::{role::UserRole, BichonUser},
};
pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
BichonUser::ensure_default_admin_exists().await
}
}
+49
View File
@@ -0,0 +1,49 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult,
users::BichonUser,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct MinimalUser {
pub id: u64,
pub username: String,
pub email: String,
}
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {
id: user.id,
username: user.username,
email: user.email,
})
.collect();
Ok(minimal_list)
}
}
+586
View File
@@ -0,0 +1,586 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decrypt, encrypt, generate_token, id,
modules::{
database::{
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
secondary_find_impl, update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{
acl::AccessControl,
payload::{UserCreateRequest, UserUpdateRequest},
permissions::Permission,
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
view::UserView,
},
},
raise_error, utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use tracing::warn;
pub mod acl;
pub mod manager;
pub mod minimal;
pub mod payload;
pub mod permissions;
pub mod role;
pub mod view;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct LoginResult {
pub success: bool,
pub error_message: Option<String>,
pub access_token: Option<String>,
}
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
impl BichonUser {
pub async fn list_all() -> BichonResult<Vec<BichonUser>> {
Ok(list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
let mut all_perms = HashSet::new();
for &role_id in &self.global_roles {
if let Ok(Some(role)) = UserRole::find(role_id).await {
for perm in role.permissions {
all_perms.insert(perm);
}
}
}
all_perms
}
pub fn to_current_user(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
let global_roles_names = self
.global_roles
.iter()
.filter_map(|role_id| role_lookup.get(role_id))
.map(|role| role.name.clone())
.collect();
let account_roles_summary = self
.account_access_map
.iter()
.map(|(acc_id, role_id)| {
let role_name = role_lookup
.get(role_id)
.map(|r| r.name.clone())
.unwrap_or_else(|| "Unknown Role".to_string());
(*acc_id, role_name)
})
.collect();
let global_permissions = {
let mut perms = BTreeSet::new();
for role_id in &self.global_roles {
if let Some(role) = role_lookup.get(role_id) {
perms.extend(role.permissions.iter().cloned());
}
}
perms.into_iter().collect()
};
let account_permissions = {
let mut map: BTreeMap<u64, BTreeSet<String>> = BTreeMap::new();
for (account_id, role_id) in &self.account_access_map {
if let Some(role) = role_lookup.get(role_id) {
let entry = map.entry(*account_id).or_default();
entry.extend(role.permissions.iter().cloned());
}
}
map.into_iter()
.map(|(acc_id, perms)| (acc_id, perms.into_iter().collect()))
.collect()
};
UserView {
id: self.id,
username: self.username,
email: self.email,
password: self.password.map(|_| "************".to_string()),
account_access_map: self.account_access_map,
account_roles_summary,
description: self.description,
global_roles: self.global_roles,
global_roles_names,
avatar: self.avatar,
created_at: self.created_at,
updated_at: self.updated_at,
acl: self.acl,
account_permissions,
global_permissions,
}
}
pub async fn is_admin(&self) -> bool {
self.get_all_permissions().await.contains(Permission::ROOT)
}
pub async fn ensure_default_admin_exists() -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
// 1. Try to get the existing admin user
let admin = rw
.get()
.primary::<BichonUser>(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if admin.is_none() {
// 2. Insert the BichonUser with the updated schema
rw.insert(BichonUser {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
password: Some(encrypt!("admin@bichon")?),
// Use global_roles as defined in our new schema
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
// Admin usually doesn't need specific scoped access
account_access_map: BTreeMap::new(),
avatar: None,
created_at: now,
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 3. Generate and insert an initial access token for the first-time setup
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Initial Setup Token".into()),
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
};
rw.upsert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await?;
Ok(())
}
pub async fn authenticate_user(
username: String,
password: String,
) -> BichonResult<LoginResult> {
let user_option = secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
username.clone(),
)
.await?;
let user = match user_option {
Some(u) => u,
None => {
match secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
username,
)
.await?
{
Some(u) => u,
None => {
return Ok(LoginResult {
success: false,
error_message: Some("User or email not found.".to_string()),
access_token: None,
});
}
}
}
};
match user.password.as_ref() {
Some(encrypted_password) => {
let decrypted = decrypt!(encrypted_password)?;
if password == decrypted {
let new_token = AccessTokenModel::reset_webui_token(user.id).await?;
Ok(LoginResult {
success: true,
error_message: None,
access_token: Some(new_token),
})
} else {
warn!(
"Login failed: Incorrect password for user '{}'.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some("Incorrect password.".to_string()),
access_token: None,
})
}
}
None => {
warn!(
"Login failed: User '{}' has no password set.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some(
format!(
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
user.username
)
),
access_token: None,
})
}
}
}
pub async fn find(user_id: u64) -> BichonResult<Option<BichonUser>> {
async_find_impl(DB_MANAGER.meta_db(), user_id).await
}
pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
// Check username duplicate
if secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
username.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate
if secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
email.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn create(request: UserCreateRequest) -> BichonResult<BichonUser> {
request.validate().await?;
Self::check_username_conflict(&request.username).await?;
Self::check_email_conflict(&request.email).await?;
let password_hash = Some(encrypt!(&request.password)?);
let now = utc_now!();
let user = BichonUser {
id: id!(96),
username: request.username,
email: request.email,
password: password_hash,
global_roles: request.global_roles,
avatar: request.avatar_base64,
description: request.description,
acl: request.acl,
created_at: now,
updated_at: now,
account_access_map: request.account_access_map,
};
let user_clone = user.clone();
// 4. Atomic transaction for User and Initial Token
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let user_id = user.id;
// Insert User
rw.insert(user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Create initial WebUI access token
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Default WebUI Token".into()),
user_id,
token_type: TokenType::WebUI,
expire_at: None,
};
rw.insert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
Ok(user_clone)
}
//delete user
pub async fn remove(id: u64) -> BichonResult<()> {
if DEFAULT_ADMIN_USER_ID == id {
return Err(raise_error!(
format!("The default admin user (id={}) cannot be removed", id),
ErrorCode::PermissionDenied
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<BichonUser>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The User with id={id} that you want to delete was not found."),
ErrorCode::ResourceNotFound
)
})
})
.await?;
batch_delete_impl(DB_MANAGER.meta_db(), move |rw| {
let tokens: Vec<AccessTokenModel> = rw
.scan()
.secondary::<AccessTokenModel>(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(tokens)
})
.await?;
Ok(())
}
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate().await?;
if DEFAULT_ADMIN_USER_ID == id && request.global_roles.is_some() {
return Err(raise_error!(
format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id),
ErrorCode::Forbidden
));
}
if let Some(username) = &request.username {
let user_option = secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
username.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
}
}
if let Some(email) = &request.email {
let user_option = secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
email.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
}
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<BichonUser>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(username) = request.username {
updated.username = username;
}
if let Some(email) = request.email {
updated.email = email;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(password) = request.password {
updated.password = Some(encrypt!(&password)?);
}
if let Some(global_roles) = request.global_roles {
updated.global_roles = global_roles;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
if let Some(account_access_map) = request.account_access_map {
updated.account_access_map = account_access_map;
}
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<BichonUser>> {
let all = Self::list_all().await?;
let result: Vec<BichonUser> = all
.into_iter()
.filter(|e| e.account_access_map.contains_key(&account_id))
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let users = Self::list_authorized_users(account_id).await?;
if users.is_empty() {
return Ok(());
}
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for user in users {
let current = rw
.get()
.primary::<BichonUser>(user.id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User {} not found", user.id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = current.clone();
if updated.account_access_map.remove(&account_id).is_some() {
updated.updated_at = now;
rw.update(current, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
}
+406
View File
@@ -0,0 +1,406 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
users::{
acl::AccessControl,
permissions::{Permission, VALID_PERMISSION_SET},
role::{RoleType, UserRole},
},
utils::decode_avatar_bytes,
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleCreateRequest {
pub name: String,
pub role_type: RoleType,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
}
impl RoleCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let trimmed_name = self.name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be empty or consist only of whitespace.".into(),
ErrorCode::InvalidParameter
));
}
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
if self.permissions.is_empty() {
return Err(raise_error!(
"Role must be assigned at least one permission.".into(),
ErrorCode::InvalidParameter
));
}
for permission in &self.permissions {
if !VALID_PERMISSION_SET.contains(permission.as_str()) {
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the request.",
permission
),
ErrorCode::InvalidParameter
));
}
}
Permission::validate_role_permissions(&self.role_type, &self.permissions)?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleUpdateRequest {
pub name: Option<String>,
pub description: Option<String>,
pub permissions: Option<BTreeSet<String>>,
}
impl RoleUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
// 1. Ensure at least one field is provided for the update
if self.name.is_none() && self.description.is_none() && self.permissions.is_none() {
return Err(raise_error!(
"Update request must contain at least one field to modify (name, description, or permissions).".into(),
ErrorCode::InvalidParameter
));
}
// 2. Validate Name if present
if let Some(name) = &self.name {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be set to an empty string or consist only of whitespace."
.into(),
ErrorCode::InvalidParameter
));
}
// Prevent renaming to reserved system names
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
}
// 3. Validate Permissions if present
if let Some(permissions) = &self.permissions {
// Ensure the role doesn't end up with zero permissions
if permissions.is_empty() {
return Err(raise_error!(
"Permissions list cannot be empty. A role must have at least one permission."
.into(),
ErrorCode::InvalidParameter
));
}
// Check for invalid permission strings using a functional approach
if let Some(invalid_permission) = permissions
.iter()
.find(|p| !VALID_PERMISSION_SET.contains(p.as_str()))
{
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the update request.",
invalid_permission
),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserCreateRequest {
pub username: String,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub password: String,
/// Global Roles: System-wide permissions (e.g., Admin, User Manager).
pub global_roles: Vec<u64>,
/// Scoped Access: List of accounts paired with specific roles.
/// This allows different permissions per account.
pub account_access_map: BTreeMap<u64, u64>,
pub acl: Option<AccessControl>,
pub avatar_base64: Option<String>,
pub description: Option<String>,
}
impl UserCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let username_len = self.username.len();
// 1. Username constraints
if username_len < 5 {
return Err(raise_error!(
"Username must be at least 5 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if username_len > 32 {
return Err(raise_error!(
"Username cannot exceed 32 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 2. Password constraints
let password_len = self.password.len();
if password_len < 8 {
return Err(raise_error!(
"Password must be at least 8 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if password_len > 32 {
return Err(raise_error!(
"Password cannot exceed 32 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 3. Global Roles validation
if self.global_roles.is_empty() {
return Err(raise_error!(
"Global roles list cannot be empty. At least one role must be selected.".into(),
ErrorCode::InvalidParameter
));
}
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
for rid in &self.global_roles {
match role_type_map.get(rid) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", rid),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", rid),
ErrorCode::InvalidParameter
))
}
}
}
for (aid, rid) in &self.account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(avatar_base64) = &self.avatar_base64 {
decode_avatar_bytes(&avatar_base64)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserUpdateRequest {
pub username: Option<String>,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: Option<String>,
pub password: Option<String>,
pub avatar_base64: Option<String>,
pub global_roles: Option<Vec<u64>>,
/// Scoped Access
pub account_access_map: Option<BTreeMap<u64, u64>>,
pub acl: Option<AccessControl>,
pub description: Option<String>,
}
impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username {
let len = username.len();
if len < 5 || len > 32 {
return Err(raise_error!(
"Username must be 5-32 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(password) = &self.password {
let len = password.len();
if len < 8 || len > 32 {
return Err(raise_error!(
"Password must be 8-32 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
if let Some(roles) = &self.global_roles {
if roles.is_empty() {
return Err(raise_error!(
"Roles list cannot be empty.".into(),
ErrorCode::InvalidParameter
));
}
for role_id in roles {
match role_type_map.get(role_id) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", role_id),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", role_id),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(account_access_map) = &self.account_access_map {
for (aid, rid) in account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description too long.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(avatar) = &self.avatar_base64 {
decode_avatar_bytes(avatar)?;
}
Ok(())
}
}
+239
View File
@@ -0,0 +1,239 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{BTreeSet, HashSet},
sync::LazyLock,
};
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
users::role::RoleType,
},
raise_error,
};
pub static VALID_PERMISSION_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
Permission::all_permissions()
.into_iter()
.map(|(key, _)| key)
.collect()
});
pub struct Permission;
impl Permission {
// ----------------------------------------------------------------------
// 1. Global Management Permissions (System, Users, Tokens)
// ----------------------------------------------------------------------
/// Basic platform access. Required for any user to log in and access the dashboard.
/// This provides no administrative powers.
pub const SYSTEM_ACCESS: &str = "system:access";
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
pub const ROOT: &str = "system:root";
/// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage";
/// View the minimal user list and basic profiles (Managers and Admins).
pub const USER_VIEW: &str = "user:view";
/// View and revoke all access tokens in the system.
pub const TOKEN_MANAGE: &str = "token:manage";
/// Create new email account connections.
pub const ACCOUNT_CREATE: &str = "account:create";
// ----------------------------------------------------------------------
// 2. Global "ALL" Scoped Permissions (Reserved for Admin)
// ----------------------------------------------------------------------
/// Manage configuration for all accounts (Global control).
pub const ACCOUNT_MANAGE_ALL: &str = "account:manage:all";
/// Read mail data from all accounts (Search, view messages).
pub const DATA_READ_ALL: &str = "data:read:all";
/// Download raw EML/MIME files from all accounts.
pub const DATA_RAW_DOWNLOAD_ALL: &str = "data:raw:download:all";
/// Delete messages from all accounts.
pub const DATA_DELETE_ALL: &str = "data:delete:all";
/// Manage metadata (e.g., tags, categories, notes) for messages in ALL email accounts.
pub const DATA_MANAGE_ALL: &str = "data:manage:all";
/// Export messages in batches from all accounts.
pub const DATA_EXPORT_BATCH_ALL: &str = "data:export:batch:all";
// ----------------------------------------------------------------------
// 3. Scoped/Limited Permissions (Manager & Viewer)
// Authorization requires checking the user's Account Access List (ACL)
// ----------------------------------------------------------------------
/// Manage (modify/delete/sync) configuration for a specific set of accounts.
pub const ACCOUNT_MANAGE: &str = "account:manage";
/// Read details and sync status for a specific set of accounts.
pub const ACCOUNT_READ_DETAILS: &str = "account:read_details";
/// Manage mail data metadata (e.g., updating tags, adding notes)
/// for specific accounts.
pub const DATA_MANAGE: &str = "data:manage";
/// Read mail data (Search, view) from a specific set of accounts.
pub const DATA_READ: &str = "data:read";
/// Download raw EML/MIME files from a specific set of accounts.
pub const DATA_RAW_DOWNLOAD: &str = "data:raw:download";
/// Delete messages from a specific set of accounts.
pub const DATA_DELETE: &str = "data:delete";
/// Export messages in batches from a specific set of accounts.
pub const DATA_EXPORT_BATCH: &str = "data:export:batch";
/// Import EML/PST data into a SPECIFIC account.
/// Authorization requires checking access to the target account_id.
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::SYSTEM_ACCESS,
"Basic platform access for dashboard and personal settings.",
),
(Self::ROOT, "Full system access and configuration."),
(Self::USER_MANAGE, "Create, update, and delete users."),
(
Self::USER_VIEW,
"Read-only access to user list and profiles.",
),
(Self::TOKEN_MANAGE, "View and revoke all active API tokens."),
(
Self::ACCOUNT_CREATE,
"Connect new email accounts to the system.",
),
(
Self::ACCOUNT_MANAGE_ALL,
"Manage configurations for all email accounts.",
),
(
Self::DATA_READ_ALL,
"Search and read messages across all accounts.",
),
(
Self::DATA_MANAGE_ALL,
"Manage metadata and tags for all accounts.",
),
(
Self::DATA_RAW_DOWNLOAD_ALL,
"Download raw EML data from any account.",
),
(
Self::DATA_DELETE_ALL,
"Permanently delete messages from any account.",
),
(
Self::DATA_EXPORT_BATCH_ALL,
"Export bulk message data from all accounts.",
),
]
}
pub fn account_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::ACCOUNT_MANAGE,
"Update or sync settings for authorized accounts.",
),
(
Self::ACCOUNT_READ_DETAILS,
"View status and details of authorized accounts.",
),
(
Self::DATA_READ,
"Read messages from authorized email accounts.",
),
(
Self::DATA_MANAGE,
"Manage tags and metadata for authorized accounts.",
),
(
Self::DATA_RAW_DOWNLOAD,
"Download raw EML files from authorized accounts.",
),
(
Self::DATA_DELETE,
"Delete messages from authorized email accounts.",
),
(
Self::DATA_EXPORT_BATCH,
"Export messages from authorized accounts.",
),
(
Self::DATA_IMPORT_BATCH,
"Import external EML/PST data into authorized accounts.",
),
]
}
pub fn all_permissions() -> Vec<(&'static str, &'static str)> {
let mut all = Self::global_permissions();
all.extend(Self::account_permissions());
all
}
fn is_account_permission(perm: &str) -> bool {
Self::account_permissions().iter().any(|(p, _)| *p == perm)
}
fn is_global_permission(perm: &str) -> bool {
Self::global_permissions().iter().any(|(p, _)| *p == perm)
}
pub fn validate_role_permissions(
role_type: &RoleType,
permissions: &BTreeSet<String>,
) -> BichonResult<()> {
for p in permissions {
match role_type {
RoleType::Global => {
if !Self::is_global_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Global permission", p),
ErrorCode::InvalidParameter
));
}
}
RoleType::Account => {
if !Self::is_account_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Account permission", p),
ErrorCode::InvalidParameter
));
}
}
}
}
Ok(())
}
}
+359
View File
@@ -0,0 +1,359 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{BTreeSet, HashSet},
fmt::{self, Display},
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
id,
modules::{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
users::{
payload::{RoleCreateRequest, RoleUpdateRequest},
permissions::*,
},
},
raise_error, utc_now,
};
/// Enumerates the built-in roles in the Bichon system.
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum BuiltinRole {
Admin,
Manager,
Member,
AccountManager,
AccountViewer,
}
impl Display for BuiltinRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
BuiltinRole::Admin => "admin",
BuiltinRole::Manager => "manager",
BuiltinRole::Member => "member",
BuiltinRole::AccountManager => "account_manager",
BuiltinRole::AccountViewer => "account_viewer",
};
write!(f, "{}", s)
}
}
impl BuiltinRole {
pub fn description(&self) -> &'static str {
match self {
BuiltinRole::Admin => {
"Full system administrator with unrestricted access to all accounts, user management, and system configurations."
}
BuiltinRole::Manager => {
"Standard operational manager. Can manage users, create accounts, and perform data operations on authorized email accounts."
}
BuiltinRole::Member => {
"Regular platform member. Provides basic login access to the system without any administrative or global management privileges."
}
BuiltinRole::AccountManager => {
"Specific account manager. Has full administrative control over a particular email account, including configuration and data deletion."
}
BuiltinRole::AccountViewer => {
"Specific account observer. Has read-only access to messages and metadata for a particular email account."
}
}
}
/// Retrieves the set of static permissions associated with the role.
pub fn get_permissions(&self) -> HashSet<&'static str> {
match self {
BuiltinRole::Admin => Self::admin_permissions(),
BuiltinRole::Manager => Self::manager_permissions(),
BuiltinRole::Member => Self::member_permissions(),
BuiltinRole::AccountManager => Self::account_owner_permissions(),
BuiltinRole::AccountViewer => Self::account_viewer_permissions(),
}
}
/// Admin Role: Full control over the system and all data.
fn admin_permissions() -> HashSet<&'static str> {
[
// System-Wide
Permission::ROOT,
Permission::USER_MANAGE,
Permission::USER_VIEW,
Permission::TOKEN_MANAGE,
// Account Configuration
Permission::ACCOUNT_CREATE,
Permission::ACCOUNT_MANAGE_ALL, // Global account management
// Data Access (Global ALL)
Permission::DATA_READ_ALL,
Permission::DATA_MANAGE_ALL,
Permission::DATA_RAW_DOWNLOAD_ALL,
Permission::DATA_DELETE_ALL,
Permission::DATA_EXPORT_BATCH_ALL,
]
.into_iter()
.collect()
}
/// Manager Role: Data and account configuration management, limited user management.
/// ALL data/account access must be scoped by the user's ACL.
fn manager_permissions() -> HashSet<&'static str> {
[Permission::USER_VIEW, Permission::ACCOUNT_CREATE]
.into_iter()
.collect()
}
fn member_permissions() -> HashSet<&'static str> {
[Permission::SYSTEM_ACCESS].into_iter().collect()
}
fn account_owner_permissions() -> HashSet<&'static str> {
[
Permission::ACCOUNT_MANAGE,
Permission::ACCOUNT_READ_DETAILS,
Permission::DATA_READ,
Permission::DATA_MANAGE,
Permission::DATA_RAW_DOWNLOAD,
Permission::DATA_DELETE,
Permission::DATA_EXPORT_BATCH,
Permission::DATA_IMPORT_BATCH,
]
.into_iter()
.collect()
}
fn account_viewer_permissions() -> HashSet<&'static str> {
[Permission::ACCOUNT_READ_DETAILS, Permission::DATA_READ]
.into_iter()
.collect()
}
}
// Global Roles (Starting with 1)
pub const DEFAULT_ADMIN_ROLE_ID: u64 = 100_000_000_000_000; // System Admin
pub const DEFAULT_MANAGER_ROLE_ID: u64 = 100_100_000_000_000; // System Manager
pub const DEFAULT_MEMBER_ROLE_ID: u64 = 100_200_000_000_000; // Regular Member (system:access)
// Account-specific Roles (Starting with 2)
pub const DEFAULT_ACCOUNT_MANAGER_ROLE_ID: u64 = 200_100_000_000_000;
pub const DEFAULT_ACCOUNT_VIEWER_ROLE_ID: u64 = 200_200_000_000_000;
fn is_builtin(id: u64) -> bool {
matches!(
id,
DEFAULT_ADMIN_ROLE_ID
| DEFAULT_MANAGER_ROLE_ID
| DEFAULT_MEMBER_ROLE_ID
| DEFAULT_ACCOUNT_MANAGER_ROLE_ID
| DEFAULT_ACCOUNT_VIEWER_ROLE_ID
)
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum RoleType {
#[default]
Global,
Account,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
pub is_builtin: bool,
pub created_at: i64,
pub role_type: RoleType,
pub updated_at: i64,
}
impl UserRole {
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
let builtin_roles = vec![
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
(
BuiltinRole::Manager,
DEFAULT_MANAGER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::Member,
DEFAULT_MEMBER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::AccountManager,
DEFAULT_ACCOUNT_MANAGER_ROLE_ID,
RoleType::Account,
),
(
BuiltinRole::AccountViewer,
DEFAULT_ACCOUNT_VIEWER_ROLE_ID,
RoleType::Account,
),
];
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for (role, role_id, role_type) in builtin_roles {
let exists = rw
.get()
.primary::<UserRole>(role_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.is_some();
if !exists {
let permissions: BTreeSet<String> = role
.get_permissions()
.into_iter()
.map(|s| s.to_string())
.collect();
rw.insert(UserRole {
id: role_id,
name: role.to_string(),
description: Some(role.description().to_string()),
permissions,
created_at: now,
updated_at: now,
is_builtin: true,
role_type,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
async_find_impl(DB_MANAGER.meta_db(), role_id).await
}
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
let _ = &request.validate().await?;
let now = utc_now!();
let new_role = UserRole {
id: id!(64),
name: request.name,
description: request.description,
permissions: request.permissions,
created_at: now,
updated_at: now,
is_builtin: false,
role_type: request.role_type,
};
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
Ok(new_role)
}
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
if is_builtin(id) && request.permissions.is_some() {
return Err(raise_error!(
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
ErrorCode::Forbidden
));
}
let _ = &request.validate().await?;
if let Some(permissions) = &request.permissions {
let role = Self::find(id).await?.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})?;
Permission::validate_role_permissions(&role.role_type, permissions)?;
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(name) = request.name {
updated.name = name;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(permissions) = request.permissions {
updated.permissions = permissions;
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn delete(id: u64) -> BichonResult<()> {
if is_builtin(id) {
return Err(raise_error!(
format!("Cannot delete a default system role (ID: {}).", id),
ErrorCode::InvalidParameter
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole '{}' not found during deletion process.", id),
ErrorCode::ResourceNotFound
)
})
})
.await
}
}
+52
View File
@@ -0,0 +1,52 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::users::acl::AccessControl;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct UserView {
pub id: u64,
pub username: String,
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub account_roles_summary: BTreeMap<u64, String>,
pub account_permissions: BTreeMap<u64, Vec<String>>,
pub description: Option<String>,
/// Global Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub global_roles_names: Vec<String>,
pub global_permissions: Vec<String>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}