//
// 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 .
use crate::{
decrypt, encrypt, generate_token, id,
modules::{
database::{
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
async_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;
pub type UserModel = BichonUserV2;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct LoginResult {
pub success: bool,
pub error_message: Option,
pub access_token: Option,
pub theme: Option,
pub language: Option,
}
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,
/// 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,
pub description: Option,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec,
pub avatar: Option,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option,
/// 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,
pub description: Option,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec,
pub avatar: Option,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option,
pub theme: Option,
pub language: Option,
}
impl BichonUserV2 {
pub async fn list_all() -> BichonResult> {
Ok(list_all_impl::(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet {
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_view(self, role_lookup: &BTreeMap) -> 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> = 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,
theme: self.theme,
language: self.language,
}
}
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::(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(UserModel {
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,
theme: None,
language: 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 {
let user_option = async_secondary_find_impl::(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.clone(),
)
.await?;
let user = match user_option {
Some(u) => u,
None => {
match async_secondary_find_impl::(
DB_MANAGER.meta_db(),
BichonUserV2Key::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,
theme: None,
language: 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),
theme: user.theme,
language: user.language,
})
} else {
warn!(
"Login failed: Incorrect password for user '{}'.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some("Incorrect password.".to_string()),
access_token: None,
theme: None,
language: 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,
theme: None,
language: None,
})
}
}
}
pub async fn find(user_id: u64) -> BichonResult