mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add multi-user support and role-based access control #31
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user