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
+160
View File
@@ -0,0 +1,160 @@
//
// 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::{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
role::{RoleType, UserRole},
BichonUser,
},
},
raise_error, utc_now,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct BatchAccountRoleRequest {
pub account_ids: Vec<u64>,
pub user_ids: Vec<u64>,
pub role_id: u64,
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
)
})?;
if !matches!(role.role_type, RoleType::Account) {
return Err(raise_error!(
"Only Account roles can be assigned to individual account".into(),
ErrorCode::InvalidParameter
));
}
for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
for id in &self.user_ids {
let exists = BichonUser::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
Ok(())
}
async fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<BichonUser>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
context
.user
.account_access_map
.get(account_id)
.ok_or_else(|| {
raise_error!(
format!("No access to account {}", account_id),
ErrorCode::Forbidden
)
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
)
})?;
// Critical Check: Does this role grant management/sharing rights?
if !user_scoped_role
.permissions
.contains(Permission::ACCOUNT_MANAGE)
{
return Err(raise_error!(
format!("Your role on account {} does not allow sharing", account_id),
ErrorCode::Forbidden
));
}
// Optional: Ensure manager isn't giving away perms they don't have
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
}
}
+133 -22
View File
@@ -29,9 +29,10 @@ use crate::{
modules::{
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
cache::imap::mailbox::MailBox,
database::{insert_impl, list_all_impl},
database::{list_all_impl, with_transaction},
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, BichonUser, DEFAULT_ADMIN_USER_ID},
},
utc_now,
};
@@ -52,10 +53,9 @@ use crate::modules::database::{
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV2;
pub type AccountModel = AccountV3;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -121,8 +121,40 @@ impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
@@ -141,12 +173,13 @@ impl AccountV2 {
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
})
}
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -176,24 +209,53 @@ impl AccountV2 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await
}
/// Saves the current `AccountEntity` by persisting it to storage.
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
// /// Saves the current `AccountEntity` by persisting it to storage.
// pub async fn save(&self) -> BichonResult<()> {
// insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
let entity = request.create_entity()?;
entity.save().await?;
if matches!(entity.account_type, AccountType::IMAP) {
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let account_id = entity.id;
rw.insert::<AccountModel>(entity)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<BichonUser>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = user.clone();
updated
.account_access_map
.insert(account_id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
updated.updated_at = utc_now!();
rw.update(user, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
if matches!(cloned.account_type, AccountType::IMAP) {
SYNC_CONTROLLER
.trigger_start(entity.id, entity.email.clone())
.trigger_start(cloned.id, cloned.email.clone())
.await;
}
Ok(entity)
Ok(cloned)
}
pub async fn update(
@@ -230,7 +292,7 @@ impl AccountV2 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
@@ -242,7 +304,7 @@ impl AccountV2 {
MAIL_CONTEXT.clean_account(account.id).await?;
}
OAuth2AccessToken::try_delete(account.id).await?;
AccessToken::cleanup_account(account.id).await?;
BichonUser::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
ENVELOPE_INDEX_MANAGER
.delete_account_envelopes(account.id)
@@ -260,7 +322,7 @@ impl AccountV2 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -275,7 +337,7 @@ impl AccountV2 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -290,7 +352,7 @@ impl AccountV2 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -319,7 +381,7 @@ impl AccountV2 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
.await
}
@@ -450,3 +512,52 @@ impl From<AccountV2> for AccountV1 {
}
}
}
impl From<AccountV3> for AccountV2 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
+3 -2
View File
@@ -16,10 +16,11 @@
// 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/>.
pub mod dispatcher;
pub mod entity;
pub mod grant;
pub mod migration;
pub mod payload;
pub mod since;
pub mod state;
pub mod migration;
pub mod view;
+4 -7
View File
@@ -16,14 +16,11 @@
// 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;
use crate::modules::account::entity::ImapConfig;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::account::since::DateSince;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::token::AccountInfo;
use crate::{raise_error, validate_email};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -47,7 +44,7 @@ pub struct AccountCreateRequest {
}
impl AccountCreateRequest {
pub fn create_entity(self) -> BichonResult<AccountModel> {
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
@@ -71,7 +68,7 @@ impl AccountCreateRequest {
}
AccountType::NoSync => {}
}
Ok(AccountModel::new(self)?)
Ok(AccountModel::new(user_id, self)?)
}
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
@@ -167,11 +164,11 @@ pub struct MinimalAccount {
pub fn filter_accessible_accounts<'a>(
all_accounts: &'a [MinimalAccount],
allowed: &BTreeSet<AccountInfo>,
allowed: &Vec<u64>,
) -> Vec<MinimalAccount> {
all_accounts
.iter()
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
.filter(|acct| allowed.contains(&acct.id))
.cloned()
.collect()
}
+87
View File
@@ -0,0 +1,87 @@
//
// 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, HashMap};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::DateSince,
},
users::BichonUser,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountResp {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub created_user_name: String,
pub created_user_email: String,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, BichonUser>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,
imap: account.imap,
enabled: account.enabled,
email: account.email,
name: account.name,
capabilities: account.capabilities,
date_since: account.date_since,
folder_limit: account.folder_limit,
sync_folders: account.sync_folders,
account_type: account.account_type,
sync_interval_min: account.sync_interval_min,
known_folders: account.known_folders,
created_at: account.created_at,
updated_at: account.updated_at,
created_by: account.created_by,
created_user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
created_user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key,
}
}
}