Every one of the 14 was `#[cfg(not(feature = "enterprise"))]` on the arm the fork always compiles: the Enterprise arms went with the import, and nothing turns the feature on. Removing the attribute leaves the same code, now unconditional, in 11 files. Two of them looked like behavior worth checking before touching: the `validate_tenant_quota` stub that always passes, and the refusal to cancel a pending DestroyAccount task. The stub is vestigial — the rebuilt multi-tenancy enforces quotas in `crates/features/src/tenancy/quota.rs` for those objects and more — and the refusal is undelete's open question, which this change leaves exactly as it was. The binary builds with no new warnings, and `system_tests` and `jmap_tests`, which cover the touched registry, task-manager and auth paths, both pass. The feature definitions stay in the manifests, inert: taking them out would widen every sync's diff for nothing.
436 lines
16 KiB
Rust
436 lines
16 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use crate::registry::mapping::{ObjectResponse, ValidationResult};
|
|
use common::{
|
|
Server,
|
|
auth::{AccessToken, Permissions, PermissionsGroup, permissions::BuildPermissions},
|
|
};
|
|
use directory::core::secret::{hash_secret, is_password_hash};
|
|
use jmap_proto::error::set::SetError;
|
|
use registry::{schema::structs::TaskStatus, types::datetime::UTCDateTime};
|
|
use registry::{
|
|
schema::{
|
|
enums::{AccountType, Permission, TenantStorageQuota},
|
|
prelude::{MASKED_PASSWORD, ObjectType, Property},
|
|
structs::{Account, Credential, Role, Task, TaskDestroyAccount},
|
|
},
|
|
types::EnumImpl,
|
|
};
|
|
use store::write::{BatchBuilder, RegistryClass, ValueClass, now};
|
|
use trc::AddContext;
|
|
use types::id::Id;
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum AccountUpdate<'x> {
|
|
Update(&'x Account),
|
|
Create(&'x str),
|
|
}
|
|
|
|
pub async fn validate_account(
|
|
server: &Server,
|
|
access_token: &AccessToken,
|
|
mut account: &mut Account,
|
|
old_account: AccountUpdate<'_>,
|
|
) -> ValidationResult {
|
|
|
|
let is_external_directory = if let Account::User(account) = account {
|
|
server
|
|
.domain_by_id(account.domain_id.document_id())
|
|
.await?
|
|
.and_then(|domain| server.get_directory_for_cached_domain(&domain))
|
|
.is_some()
|
|
} else {
|
|
false
|
|
};
|
|
let recover_account_id = if server.registry().is_recovery_mode()
|
|
&& let AccountUpdate::Create(client_id) = old_account
|
|
&& let Some(account_id) = client_id
|
|
.strip_prefix("restore-")
|
|
.and_then(|id| id.parse::<u32>().ok())
|
|
{
|
|
Some(account_id)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let validate_permissions = match (&mut account, old_account) {
|
|
(Account::User(account), AccountUpdate::Update(Account::User(old_account))) => {
|
|
// Validate credentials
|
|
let has_password = account.credentials.values().any(|credential| {
|
|
matches!(credential, Credential::Password(credential) if credential.credential_id.is_valid())
|
|
});
|
|
let mut max_credential_id = 0;
|
|
let mut has_new_credentials = false;
|
|
for credential in account.credentials.values_mut() {
|
|
let credential_id = credential.credential_id();
|
|
|
|
if credential_id.is_valid() && credential_id.id() > max_credential_id {
|
|
max_credential_id = credential_id.id();
|
|
}
|
|
|
|
if let Some(old_credential) = old_account
|
|
.credentials
|
|
.values()
|
|
.find(|c| c.credential_id() == credential_id)
|
|
{
|
|
if credential != old_credential {
|
|
match (credential, old_credential) {
|
|
(
|
|
Credential::Password(credential),
|
|
Credential::Password(old_credential),
|
|
) => {
|
|
if is_external_directory {
|
|
return Ok(Err(SetError::forbidden().with_description(
|
|
"Cannot change credentials for accounts in an external directory.",
|
|
)));
|
|
}
|
|
|
|
// Reset the original password if the client accidentally sent the masked password
|
|
if credential.secret == MASKED_PASSWORD {
|
|
credential.secret = old_credential.secret.clone();
|
|
}
|
|
if credential
|
|
.otp_auth
|
|
.as_ref()
|
|
.is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD)
|
|
{
|
|
credential.otp_auth = old_credential.otp_auth.clone();
|
|
}
|
|
|
|
if credential.secret != old_credential.secret {
|
|
if credential.expires_at == old_credential.expires_at
|
|
&& credential
|
|
.expires_at
|
|
.is_some_and(|exp| exp.timestamp() <= now() as i64)
|
|
&& let Some(expires_at) =
|
|
server.core.network.security.password_default_expiration
|
|
{
|
|
credential.expires_at = Some(UTCDateTime::from_timestamp(
|
|
(now() + expires_at) as i64,
|
|
));
|
|
}
|
|
|
|
if !(matches!(
|
|
credential.secret.as_bytes().first(),
|
|
Some(&b'$' | &b'{')
|
|
) && is_password_hash(&credential.secret))
|
|
{
|
|
if let Err(err) =
|
|
server.is_secure_password(&credential.secret, &[])
|
|
{
|
|
return Ok(Err(SetError::invalid_properties()
|
|
.with_property(Property::Secret)
|
|
.with_description(err)));
|
|
}
|
|
|
|
credential.secret = hash_secret(
|
|
server.core.network.security.password_hash_algorithm,
|
|
std::mem::take(&mut credential.secret).into_bytes(),
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
}
|
|
}
|
|
}
|
|
(
|
|
Credential::AppPassword(credential),
|
|
Credential::AppPassword(old_credential),
|
|
)
|
|
| (
|
|
Credential::ApiKey(credential),
|
|
Credential::ApiKey(old_credential),
|
|
) => {
|
|
// Reset the original password if the client accidentally sent the masked password
|
|
if credential.secret == MASKED_PASSWORD {
|
|
credential.secret = old_credential.secret.clone();
|
|
}
|
|
|
|
if credential.secret != old_credential.secret {
|
|
return Ok(Err(SetError::forbidden().with_description(
|
|
"Cannot change app password or API credentials through this method.",
|
|
)));
|
|
}
|
|
}
|
|
_ => {
|
|
return Ok(Err(SetError::invalid_properties()
|
|
.with_property(Property::Credentials)
|
|
.with_description("Credential type cannot be changed.")));
|
|
}
|
|
}
|
|
}
|
|
} else if let Err(err) = validate_credential_creation(
|
|
server,
|
|
credential,
|
|
is_external_directory,
|
|
has_password,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(Err(err));
|
|
} else {
|
|
has_new_credentials = true;
|
|
}
|
|
}
|
|
|
|
if has_new_credentials {
|
|
for credential in account.credentials.values_mut() {
|
|
if !credential.credential_id().is_valid() {
|
|
max_credential_id += 1;
|
|
credential.set_credential_id(Id::from(max_credential_id));
|
|
}
|
|
}
|
|
}
|
|
|
|
account.permissions != old_account.permissions || account.roles != old_account.roles
|
|
}
|
|
(Account::Group(account), AccountUpdate::Update(Account::Group(old_account))) => {
|
|
account.permissions != old_account.permissions || account.roles != old_account.roles
|
|
}
|
|
(Account::User(account), AccountUpdate::Create(_)) => {
|
|
// Validate tenant quotas
|
|
if let Err(err) =
|
|
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxAccounts).await?
|
|
{
|
|
return Ok(Err(err));
|
|
}
|
|
|
|
// Validate credentials
|
|
for (index, credential) in account.credentials.values_mut().enumerate() {
|
|
if let Err(err) = validate_credential_creation(
|
|
server,
|
|
credential,
|
|
is_external_directory,
|
|
index > 0,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(Err(err));
|
|
}
|
|
credential.set_credential_id(Id::from(index as u64));
|
|
}
|
|
|
|
true
|
|
}
|
|
(Account::Group(_), AccountUpdate::Create(_)) => {
|
|
// Validate tenant quotas
|
|
if let Err(err) =
|
|
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxGroups).await?
|
|
{
|
|
return Ok(Err(err));
|
|
}
|
|
|
|
true
|
|
}
|
|
(Account::User(_), AccountUpdate::Update(Account::Group(_)))
|
|
| (Account::Group(_), AccountUpdate::Update(Account::User(_))) => {
|
|
return Ok(Err(SetError::invalid_properties()
|
|
.with_property(Property::Type)
|
|
.with_description(
|
|
"Cannot change the type of an existing account.",
|
|
)));
|
|
}
|
|
};
|
|
|
|
let mut result = if validate_permissions {
|
|
Ok(server
|
|
.can_set_permissions(access_token, account)
|
|
.await?
|
|
.map(|_| ObjectResponse::default())
|
|
.map_err(build_set_error))
|
|
} else {
|
|
Ok(Ok(ObjectResponse::default()))
|
|
};
|
|
|
|
if let Some(account_id) = recover_account_id
|
|
&& let Ok(Ok(result)) = &mut result
|
|
{
|
|
restore_account_id(server, account_id).await?;
|
|
result.id = Some(account_id.into());
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
async fn validate_credential_creation(
|
|
server: &Server,
|
|
credential: &mut Credential,
|
|
is_external_directory: bool,
|
|
has_password: bool,
|
|
) -> trc::Result<Result<(), SetError<Property>>> {
|
|
match credential {
|
|
Credential::Password(credential) => {
|
|
if is_external_directory {
|
|
return Ok(Err(SetError::forbidden().with_description(
|
|
"Cannot set credentials for accounts in an external directory.",
|
|
)));
|
|
} else if has_password {
|
|
return Ok(Err(SetError::invalid_properties()
|
|
.with_property(Property::Credentials)
|
|
.with_description("Only one password credential is allowed.")));
|
|
}
|
|
|
|
if credential.expires_at.is_none()
|
|
&& let Some(expires_at) = server.core.network.security.password_default_expiration
|
|
{
|
|
credential.expires_at =
|
|
Some(UTCDateTime::from_timestamp((now() + expires_at) as i64));
|
|
}
|
|
|
|
if matches!(credential.secret.as_bytes().first(), Some(&b'$' | &b'{'))
|
|
&& is_password_hash(&credential.secret)
|
|
{
|
|
Ok(Ok(()))
|
|
} else if let Err(err) = server.is_secure_password(&credential.secret, &[]) {
|
|
Ok(Err(SetError::invalid_properties()
|
|
.with_property(Property::Secret)
|
|
.with_description(err)))
|
|
} else {
|
|
credential.secret = hash_secret(
|
|
server.core.network.security.password_hash_algorithm,
|
|
std::mem::take(&mut credential.secret).into_bytes(),
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
Ok(Ok(()))
|
|
}
|
|
}
|
|
Credential::AppPassword(_) | Credential::ApiKey(_) => {
|
|
Ok(Err(SetError::invalid_properties()
|
|
.with_property(Property::Credentials)
|
|
.with_description(
|
|
"Secondary credentials cannot be set directly.",
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn validate_role(
|
|
server: &Server,
|
|
access_token: &AccessToken,
|
|
role: &mut Role,
|
|
old_role: Option<&Role>,
|
|
) -> ValidationResult {
|
|
if old_role.is_none() {
|
|
// Validate tenant quotas
|
|
if let Err(err) =
|
|
validate_tenant_quota(server, access_token, TenantStorageQuota::MaxRoles).await?
|
|
{
|
|
return Ok(Err(err));
|
|
}
|
|
}
|
|
|
|
if old_role.is_none_or(|old_role| {
|
|
old_role.enabled_permissions != role.enabled_permissions
|
|
|| old_role.disabled_permissions != role.disabled_permissions
|
|
|| old_role.role_ids != role.role_ids
|
|
}) {
|
|
Ok(access_token
|
|
.can_grant_permissions(
|
|
PermissionsGroup {
|
|
enabled: Permissions::from_permission(role.enabled_permissions.as_slice()),
|
|
disabled: Permissions::from_permission(role.disabled_permissions.as_slice()),
|
|
merge: false,
|
|
}
|
|
.finalize(),
|
|
)
|
|
.map(|_| ObjectResponse::default())
|
|
.map_err(build_set_error))
|
|
} else {
|
|
Ok(Ok(ObjectResponse::default()))
|
|
}
|
|
}
|
|
|
|
|
|
pub async fn validate_tenant_quota(
|
|
_server: &Server,
|
|
_access_token: &AccessToken,
|
|
_quota: TenantStorageQuota,
|
|
) -> ValidationResult {
|
|
ValidationResult::Ok(Ok(ObjectResponse::default()))
|
|
}
|
|
|
|
pub async fn schedule_account_destruction(
|
|
server: &Server,
|
|
account_id: Id,
|
|
account: &Account,
|
|
) -> trc::Result<()> {
|
|
|
|
let status = TaskStatus::now();
|
|
|
|
let (account_domain_id, account_name, account_type) = match account {
|
|
Account::User(account) => (account.domain_id, account.name.clone(), AccountType::User),
|
|
Account::Group(account) => (account.domain_id, account.name.clone(), AccountType::Group),
|
|
};
|
|
|
|
let mut batch = BatchBuilder::new();
|
|
batch.schedule_task(Task::DestroyAccount(TaskDestroyAccount {
|
|
account_domain_id,
|
|
account_id,
|
|
account_name,
|
|
account_type,
|
|
status,
|
|
}));
|
|
|
|
server.store().write(batch.build_all()).await?;
|
|
server.notify_task_queue();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn build_set_error(permissions: Vec<Permission>) -> SetError<Property> {
|
|
let mut missing_permissions = String::with_capacity(16);
|
|
let mut total_missing = permissions.len();
|
|
for permission in permissions.into_iter().take(5) {
|
|
if !missing_permissions.is_empty() {
|
|
missing_permissions.push_str(", ");
|
|
}
|
|
missing_permissions.push_str(permission.as_str());
|
|
total_missing -= 1;
|
|
}
|
|
if total_missing > 0 {
|
|
missing_permissions.push_str(&format!(" and {} more", total_missing));
|
|
}
|
|
|
|
SetError::forbidden().with_description(format!(
|
|
"You are not authorized to grant permissions: {}",
|
|
missing_permissions
|
|
))
|
|
}
|
|
|
|
async fn restore_account_id(server: &Server, id: u32) -> trc::Result<()> {
|
|
// Obtain current counter value
|
|
let object_id = ObjectType::Account.to_id();
|
|
let last_id = server
|
|
.store()
|
|
.get_counter(ValueClass::Registry(RegistryClass::IdCounter { object_id }))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.cast_unsigned() as u32;
|
|
|
|
if last_id < id {
|
|
let mut id_batch = BatchBuilder::new();
|
|
id_batch.add_and_get(
|
|
ValueClass::Registry(RegistryClass::IdCounter { object_id }),
|
|
(id - last_id) as i64,
|
|
);
|
|
let last_id = server
|
|
.store()
|
|
.write(id_batch.build_all())
|
|
.await
|
|
.and_then(|v| v.last_counter_id())?;
|
|
|
|
if last_id < id as i64 {
|
|
return Err(trc::StoreEvent::UnexpectedError
|
|
.into_err()
|
|
.details("Failed to update id counter")
|
|
.caused_by(trc::location!()));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|