JMAP Registry API implementation - part 6
This commit is contained in:
@@ -217,6 +217,15 @@ impl RegistryGet for Server {
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
if (is_tenant_filtered
|
||||
&& access_token.tenant_id().map(Id::from)
|
||||
!= object.inner.member_tenant_id())
|
||||
|| (is_account_filtered
|
||||
&& object.inner.account_id() != Some(Id::from(get.account_id)))
|
||||
{
|
||||
get.not_found(id);
|
||||
continue;
|
||||
}
|
||||
object
|
||||
} else if id.is_singleton() && is_singleton {
|
||||
Object::from(object_type)
|
||||
|
||||
145
crates/jmap/src/registry/mapping/masked_email.rs
Normal file
145
crates/jmap/src/registry/mapping/masked_email.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
||||
use common::network::masked::MaskedAddress;
|
||||
use jmap_proto::error::set::SetError;
|
||||
use rand::{Rng, distr::Alphanumeric};
|
||||
use registry::{
|
||||
jmap::JmapValue,
|
||||
schema::{
|
||||
enums::StorageQuota,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::MaskedEmail,
|
||||
},
|
||||
};
|
||||
use store::{ahash::AHashSet, registry::RegistryQuery, write::now};
|
||||
use utils::{DomainPart, map::vec_map::VecMap};
|
||||
|
||||
pub(crate) async fn validate_masked_email(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
addr: &mut MaskedEmail,
|
||||
is_create: bool,
|
||||
unpatched_properties: VecMap<Property, JmapValue<'_>>,
|
||||
) -> ValidationResult {
|
||||
let mut response = ObjectResponse::default();
|
||||
|
||||
if is_create {
|
||||
// Validate quotas
|
||||
let num_masked = set
|
||||
.server
|
||||
.registry()
|
||||
.count(RegistryQuery::new(ObjectType::MaskedEmail).with_account(set.account_id))
|
||||
.await? as u32;
|
||||
let account = set.server.account(set.account_id).await?;
|
||||
let masked_quota = set
|
||||
.server
|
||||
.object_quota(account.object_quotas(), StorageQuota::MaxMaskedAddresses);
|
||||
if num_masked >= masked_quota {
|
||||
return Ok(Err(SetError::over_quota().with_description(format!(
|
||||
"You have exceeded your quota of {} masked addresses.",
|
||||
masked_quota
|
||||
))));
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
let mut requested_domain = None;
|
||||
let mut requested_prefix = None;
|
||||
for (key, value) in unpatched_properties {
|
||||
match (key, value) {
|
||||
(Property::EmailPrefix, JmapValue::Str(prefix))
|
||||
if (1..=64).contains(&prefix.len())
|
||||
&& prefix
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& prefix.as_bytes().first().is_some_and(|v| *v != b'_') =>
|
||||
{
|
||||
requested_prefix = Some(prefix.to_lowercase());
|
||||
}
|
||||
(Property::EmailDomain, JmapValue::Str(domain)) if !domain.is_empty() => {
|
||||
let domain = domain.to_lowercase();
|
||||
if set
|
||||
.server
|
||||
.domain(&domain)
|
||||
.await?
|
||||
.filter(|domain| {
|
||||
account
|
||||
.addresses
|
||||
.iter()
|
||||
.all(|addr| addr.domain_id == domain.id)
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
requested_domain = Some(domain);
|
||||
}
|
||||
if requested_domain.is_none() {
|
||||
return Ok(Err(SetError::forbidden()
|
||||
.with_property(key)
|
||||
.with_description(
|
||||
"The specified domain is not valid for this account.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
(_, JmapValue::Null) => {}
|
||||
_ => {
|
||||
return Ok(Err(SetError::invalid_properties().with_property(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not specified, use the first available domain and a random prefix
|
||||
let domain = if let Some(domain) = requested_domain {
|
||||
domain
|
||||
} else {
|
||||
let Some(domain) = account.name.try_domain_part() else {
|
||||
return Ok(Err(SetError::forbidden()
|
||||
.with_property(Property::EmailDomain)
|
||||
.with_description(
|
||||
"No valid domain is available for this account.",
|
||||
)));
|
||||
};
|
||||
domain.to_string()
|
||||
};
|
||||
let prefix = if let Some(prefix) = requested_prefix {
|
||||
prefix
|
||||
} else {
|
||||
rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(16)
|
||||
.map(|ch| char::from(ch.to_ascii_lowercase()))
|
||||
.collect::<String>()
|
||||
};
|
||||
|
||||
let address_id = set.server.registry().assign_id();
|
||||
addr.email = MaskedAddress::generate(
|
||||
address_id,
|
||||
addr.expires_at
|
||||
.map(|t| (t.timestamp() as u64).saturating_sub(now()))
|
||||
.filter(|t| *t > 0)
|
||||
.map(|t| t as u32),
|
||||
&prefix,
|
||||
&domain,
|
||||
);
|
||||
|
||||
response.id = Some(address_id.into());
|
||||
response
|
||||
.object
|
||||
.insert_unchecked(Property::Email, addr.email.clone());
|
||||
} else {
|
||||
for (key, value) in unpatched_properties {
|
||||
match (key, value) {
|
||||
(Property::Email, JmapValue::Str(email)) if email == addr.email => {}
|
||||
_ => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(key)
|
||||
.with_description("Cannot modify read-only property")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Ok(response))
|
||||
}
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{get::GetResponse, set::SetResponse},
|
||||
object::registry::Registry,
|
||||
};
|
||||
use jmap_tools::Map;
|
||||
use registry::{
|
||||
jmap::JmapValue,
|
||||
jmap::{JmapValue, RegistryValue},
|
||||
schema::prelude::{ObjectType, Property},
|
||||
};
|
||||
use store::ahash::AHashSet;
|
||||
@@ -20,6 +22,9 @@ use utils::map::vec_map::VecMap;
|
||||
pub mod account;
|
||||
pub mod deleted_item;
|
||||
pub mod log;
|
||||
pub mod masked_email;
|
||||
pub mod principal;
|
||||
pub mod public_key;
|
||||
pub mod queued_message;
|
||||
pub mod report;
|
||||
pub mod spam_sample;
|
||||
@@ -52,3 +57,28 @@ pub(crate) struct RegistrySetResponse<'x> {
|
||||
pub is_tenant_filtered: bool,
|
||||
pub is_account_filtered: bool,
|
||||
}
|
||||
|
||||
pub type ValidationResult = trc::Result<Result<ObjectResponse, SetError<Property>>>;
|
||||
|
||||
pub struct ObjectResponse {
|
||||
pub id: Option<Id>,
|
||||
pub object: Map<'static, Property, RegistryValue>,
|
||||
}
|
||||
|
||||
impl ObjectResponse {
|
||||
pub fn new(id: Id, object: Map<'static, Property, RegistryValue>) -> Self {
|
||||
Self {
|
||||
id: Some(id),
|
||||
object,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ObjectResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
object: Map::with_capacity(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
crates/jmap/src/registry/mapping/principal.rs
Normal file
248
crates/jmap/src/registry/mapping/principal.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
||||
use common::auth::PermissionsGroup;
|
||||
use directory::core::secret::hash_secret;
|
||||
use jmap_proto::error::set::SetError;
|
||||
use rand::{Rng, distr::Alphanumeric};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AccountType, Permission, TenantStorageQuota},
|
||||
prelude::{MASKED_PASSWORD, ObjectType, Property},
|
||||
structs::{Account, Role},
|
||||
},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use store::registry::RegistryQuery;
|
||||
use trc::AddContext;
|
||||
|
||||
pub(crate) async fn validate_account(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
mut account: &mut Account,
|
||||
old_account: Option<&Account>,
|
||||
) -> ValidationResult {
|
||||
// SPDX-SnippetBegin
|
||||
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
// SPDX-License-Identifier: LicenseRef-SEL
|
||||
#[cfg(feature = "enterprise")]
|
||||
if set.server.core.is_enterprise_edition()
|
||||
&& old_account.is_none()
|
||||
&& !set.server.can_create_account().await?
|
||||
{
|
||||
return Ok(Err(SetError::forbidden().with_description(format!(
|
||||
"Enterprise licensed account limit reached: {} accounts licensed.",
|
||||
set.server.licensed_accounts()
|
||||
))));
|
||||
}
|
||||
// SPDX-SnippetEnd
|
||||
|
||||
let is_external_directory = if let Account::User(account) = account {
|
||||
set.server
|
||||
.domain_by_id(account.domain_id.document_id())
|
||||
.await?
|
||||
.and_then(|domain| domain.id_directory)
|
||||
.and_then(|domain_id| set.server.get_directory(&domain_id))
|
||||
.or_else(|| set.server.get_default_directory())
|
||||
.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let validate_permissions = match (&mut account, old_account) {
|
||||
(Account::User(account), Some(Account::User(old_account))) => {
|
||||
// Reset the original password if the client accidentally sent the masked password
|
||||
if account.secret == MASKED_PASSWORD {
|
||||
account.secret = old_account.secret.clone();
|
||||
}
|
||||
if account
|
||||
.otp_auth
|
||||
.as_ref()
|
||||
.is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD)
|
||||
{
|
||||
account.otp_auth = old_account.otp_auth.clone();
|
||||
}
|
||||
|
||||
// Hash secret if it was changed and not using external auth
|
||||
if account.secret != old_account.secret {
|
||||
if is_external_directory {
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"Cannot change password for accounts in an external directory.",
|
||||
)));
|
||||
}
|
||||
if !account.secret.is_empty() {
|
||||
account.secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
std::mem::take(&mut account.secret),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
} else {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Secret)
|
||||
.with_description("Password cannot be empty.")));
|
||||
}
|
||||
}
|
||||
|
||||
if is_external_directory && account.otp_auth.is_some() {
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"Cannot set OTP auth for accounts in an external directory.",
|
||||
)));
|
||||
}
|
||||
|
||||
account.permissions != old_account.permissions || account.roles != old_account.roles
|
||||
}
|
||||
(Account::Group(account), Some(Account::Group(old_account))) => {
|
||||
account.permissions != old_account.permissions || account.roles != old_account.roles
|
||||
}
|
||||
(Account::User(account), None) => {
|
||||
// Validate tenant quotas
|
||||
if let Err(err) = validate_tenant_quota(set, TenantStorageQuota::MaxAccounts).await? {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
|
||||
if is_external_directory {
|
||||
if account.otp_auth.is_some() {
|
||||
return Ok(Err(SetError::forbidden().with_description(
|
||||
"Cannot set OTP auth for accounts in an external directory.",
|
||||
)));
|
||||
}
|
||||
|
||||
account.secret = rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
}
|
||||
|
||||
if !account.secret.is_empty() {
|
||||
account.secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
std::mem::take(&mut account.secret),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
} else {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(Property::Secret)
|
||||
.with_description("Password cannot be empty.")));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
(Account::Group(_), None) => {
|
||||
// Validate tenant quotas
|
||||
if let Err(err) = validate_tenant_quota(set, TenantStorageQuota::MaxGroups).await? {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if validate_permissions {
|
||||
Ok(set
|
||||
.server
|
||||
.can_set_permissions(set.access_token, account)
|
||||
.await?
|
||||
.map(|_| ObjectResponse::default())
|
||||
.map_err(build_set_error))
|
||||
} else {
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_role(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
role: &mut Role,
|
||||
old_role: Option<&Role>,
|
||||
) -> ValidationResult {
|
||||
if old_role.is_none() {
|
||||
// Validate tenant quotas
|
||||
if let Err(err) = validate_tenant_quota(set, TenantStorageQuota::MaxRoles).await? {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
|
||||
if old_role.is_none_or(|old_role| {
|
||||
old_role.permissions != role.permissions || old_role.role_ids != role.role_ids
|
||||
}) {
|
||||
Ok(set
|
||||
.access_token
|
||||
.can_grant_permissions(PermissionsGroup::from(&role.permissions).finalize())
|
||||
.map(|_| ObjectResponse::default())
|
||||
.map_err(build_set_error))
|
||||
} else {
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn validate_tenant_quota(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
quota: TenantStorageQuota,
|
||||
) -> ValidationResult {
|
||||
if let Some(tenant_id) = set.access_token.tenant_id() {
|
||||
let tenant = set.server.tenant(tenant_id).await?;
|
||||
if let Some(quotas) = tenant
|
||||
.quota_objects
|
||||
.as_ref()
|
||||
.map(|quotas| quotas.get(quota))
|
||||
.filter(|quota| *quota != u32::MAX)
|
||||
{
|
||||
let (object_type, type_filter, description) = match quota {
|
||||
TenantStorageQuota::MaxAccounts => {
|
||||
(ObjectType::Account, Some(AccountType::User), "accounts")
|
||||
}
|
||||
TenantStorageQuota::MaxGroups => {
|
||||
(ObjectType::Account, Some(AccountType::Group), "groups")
|
||||
}
|
||||
TenantStorageQuota::MaxDomains => (ObjectType::Domain, None, "domains"),
|
||||
TenantStorageQuota::MaxMailingLists => {
|
||||
(ObjectType::MailingList, None, "mailing lists")
|
||||
}
|
||||
TenantStorageQuota::MaxRoles => (ObjectType::Role, None, "roles"),
|
||||
TenantStorageQuota::MaxOauthClients => {
|
||||
(ObjectType::OAuthClient, None, "OAuth clients")
|
||||
}
|
||||
TenantStorageQuota::MaxDiskQuota => unreachable!(),
|
||||
};
|
||||
let mut query = RegistryQuery::new(object_type).with_tenant(tenant_id.into());
|
||||
if let Some(type_filter) = type_filter {
|
||||
query = query.equal(Property::Type, type_filter.to_id());
|
||||
}
|
||||
let count = set.server.registry().count(query).await? as u32;
|
||||
if count >= quotas {
|
||||
return Ok(Err(SetError::over_quota().with_description(format!(
|
||||
"You have exceeded your quota of {} {}.",
|
||||
quotas, description
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Ok(ObjectResponse::default()))
|
||||
}
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
50
crates/jmap/src/registry/mapping/public_key.rs
Normal file
50
crates/jmap/src/registry/mapping/public_key.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
||||
use jmap_proto::error::set::SetError;
|
||||
use registry::{
|
||||
jmap::JmapValue,
|
||||
schema::{
|
||||
enums::StorageQuota,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::PublicKey,
|
||||
},
|
||||
};
|
||||
use store::{ahash::AHashSet, registry::RegistryQuery};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub(crate) async fn validate_public_key(
|
||||
set: &RegistrySetResponse<'_>,
|
||||
key: &mut PublicKey,
|
||||
old_key: Option<&PublicKey>,
|
||||
unpatched_properties: VecMap<Property, JmapValue<'_>>,
|
||||
) -> ValidationResult {
|
||||
let mut response = ObjectResponse::default();
|
||||
|
||||
let todo = "validate key";
|
||||
|
||||
if old_key.is_none() {
|
||||
// Validate quotas
|
||||
let num_masked = set
|
||||
.server
|
||||
.registry()
|
||||
.count(RegistryQuery::new(ObjectType::PublicKey).with_account(set.account_id))
|
||||
.await? as u32;
|
||||
let account = set.server.account(set.account_id).await?;
|
||||
let masked_quota = set
|
||||
.server
|
||||
.object_quota(account.object_quotas(), StorageQuota::MaxPublicKeys);
|
||||
if num_masked >= masked_quota {
|
||||
return Ok(Err(SetError::over_quota().with_description(format!(
|
||||
"You have exceeded your quota of {} public keys.",
|
||||
masked_quota
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
todo!()
|
||||
}
|
||||
@@ -50,6 +50,7 @@ pub(crate) async fn report_get(
|
||||
internal_report_ids(get.server, object_id, get.server.core.jmap.get_max_objects).await?
|
||||
};
|
||||
|
||||
let tenant_id = get.access_token.tenant_id().map(Id::from);
|
||||
for id in ids {
|
||||
if let Some(report) = get
|
||||
.server
|
||||
@@ -60,7 +61,11 @@ pub(crate) async fn report_get(
|
||||
})))
|
||||
.await?
|
||||
{
|
||||
get.insert(id, report.into_value());
|
||||
if !get.is_tenant_filtered || report.inner.member_tenant_id() == tenant_id {
|
||||
get.insert(id, report.into_value());
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
} else {
|
||||
get.not_found(id);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
use crate::registry::mapping::RegistryGetResponse;
|
||||
use registry::{
|
||||
jmap::IntoValue,
|
||||
schema::{
|
||||
enums::Permission,
|
||||
prelude::{Object, ObjectInner, Property},
|
||||
},
|
||||
schema::prelude::{Object, ObjectInner, Property},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use store::{
|
||||
@@ -28,7 +25,7 @@ pub(crate) async fn spam_sample_get(
|
||||
let ids = if let Some(ids) = get.ids.take() {
|
||||
ids
|
||||
} else {
|
||||
let query = if get.access_token.has_permission(Permission::Impersonate) {
|
||||
let query = if !get.is_account_filtered {
|
||||
RegistryQuery::new(get.object_type).greater_than_or_equal(Property::AccountId, 0u64)
|
||||
} else {
|
||||
RegistryQuery::new(get.object_type).with_account(get.account_id)
|
||||
@@ -57,6 +54,13 @@ pub(crate) async fn spam_sample_get(
|
||||
if get.is_account_filtered
|
||||
&& let ObjectInner::SpamTrainingSample(item) = &mut item.inner
|
||||
{
|
||||
if item
|
||||
.account_id
|
||||
.is_none_or(|id| id.document_id() != get.account_id)
|
||||
{
|
||||
get.not_found(id);
|
||||
continue;
|
||||
}
|
||||
item.blob_id.class = BlobClass::Reserved {
|
||||
account_id: get.account_id,
|
||||
expires: item.expires_at.timestamp() as u64,
|
||||
|
||||
@@ -4,27 +4,36 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::registry::mapping::RegistrySetResponse;
|
||||
use common::{Server, auth::AccessToken};
|
||||
use crate::registry::mapping::{
|
||||
ObjectResponse, RegistrySetResponse,
|
||||
masked_email::validate_masked_email,
|
||||
principal::{validate_account, validate_role, validate_tenant_quota},
|
||||
public_key::validate_public_key,
|
||||
};
|
||||
use common::{Server, auth::AccessToken, cache::invalidate::CacheInvalidationBuilder};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
error::set::{SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::registry::Registry,
|
||||
request::IntoValid,
|
||||
};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
|
||||
use jmap_tools::{JsonPointer, JsonPointerItem, Key, Map};
|
||||
use registry::{
|
||||
jmap::JsonPointerPatch,
|
||||
jmap::{JmapValue, JsonPointerPatch, MaybeUnpatched, RegistryValue},
|
||||
schema::{
|
||||
enums::Permission,
|
||||
enums::{Permission, TenantStorageQuota},
|
||||
prelude::{
|
||||
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectType, Property,
|
||||
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
|
||||
Property,
|
||||
},
|
||||
structs::{Account, PublicKey, Role},
|
||||
},
|
||||
types::id::ObjectId,
|
||||
};
|
||||
use store::registry::write::{RegistryWrite, RegistryWriteResult};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub trait RegistrySet: Sync + Send {
|
||||
fn registry_set(
|
||||
@@ -35,9 +44,10 @@ pub trait RegistrySet: Sync + Send {
|
||||
) -> impl Future<Output = trc::Result<SetResponse<Registry>>> + Send;
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Modification {
|
||||
Create(String),
|
||||
Update(Id),
|
||||
Update { id: Id, object: Object },
|
||||
}
|
||||
|
||||
impl RegistrySet for Server {
|
||||
@@ -49,10 +59,11 @@ impl RegistrySet for Server {
|
||||
) -> trc::Result<SetResponse<Registry>> {
|
||||
let object_flags = object_type.flags();
|
||||
let is_singleton = (object_flags & OBJ_SINGLETON) != 0;
|
||||
let has_account_id = (object_flags & OBJ_FILTER_ACCOUNT) != 0;
|
||||
let is_tenant_filtered =
|
||||
(object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some();
|
||||
let is_account_filtered = (object_flags & OBJ_FILTER_ACCOUNT) != 0
|
||||
&& !access_token.has_permission(Permission::Impersonate);
|
||||
let is_account_filtered =
|
||||
has_account_id && !access_token.has_permission(Permission::Impersonate);
|
||||
|
||||
// Build response
|
||||
let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
|
||||
@@ -211,24 +222,44 @@ impl RegistrySet for Server {
|
||||
| ObjectType::Domain => {
|
||||
// Bundle modifications together
|
||||
let mut modifications = Vec::with_capacity(set.create.len() + set.update.len());
|
||||
for (id, value) in set.create {
|
||||
for (id, value) in set.create.drain() {
|
||||
modifications.push((
|
||||
Modification::Create(id),
|
||||
value,
|
||||
Object::from(set.object_type),
|
||||
));
|
||||
}
|
||||
for (id, value) in set.update {
|
||||
for (id, value) in set.update.drain(..) {
|
||||
if let Some(object) = self
|
||||
.registry()
|
||||
.get(ObjectId::new(object_type, id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
modifications.push((Modification::Update(id), value, object));
|
||||
if (is_tenant_filtered
|
||||
&& access_token.tenant_id().map(Id::from)
|
||||
!= object.inner.member_tenant_id())
|
||||
|| (is_account_filtered
|
||||
&& object.inner.account_id() != Some(Id::from(set.account_id)))
|
||||
{
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
modifications.push((
|
||||
Modification::Update {
|
||||
id,
|
||||
object: object.clone(),
|
||||
},
|
||||
value,
|
||||
object,
|
||||
));
|
||||
} else if is_singleton {
|
||||
modifications.push((
|
||||
Modification::Update(id),
|
||||
Modification::Update {
|
||||
id,
|
||||
object: Object::from(set.object_type),
|
||||
},
|
||||
value,
|
||||
Object::from(set.object_type),
|
||||
));
|
||||
@@ -238,20 +269,25 @@ impl RegistrySet for Server {
|
||||
}
|
||||
|
||||
// Process modifications
|
||||
'outer: for (modification, value, mut object) in modifications {
|
||||
let mut cache_invalidator = CacheInvalidationBuilder::default();
|
||||
'outer: for (modification, value, mut new_object) in modifications {
|
||||
// Initial validations
|
||||
let is_create = matches!(modification, Modification::Create(_));
|
||||
let mut unpatched_properties = VecMap::new();
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let ptr = match (key, &modification) {
|
||||
(Key::Property(prop), _) => {
|
||||
JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))])
|
||||
}
|
||||
(Key::Borrowed(other), Modification::Update(_)) => {
|
||||
(Key::Borrowed(other), Modification::Update { .. }) => {
|
||||
JsonPointer::parse(other)
|
||||
}
|
||||
(Key::Owned(other), Modification::Update(_)) => {
|
||||
(Key::Owned(other), Modification::Update { .. }) => {
|
||||
JsonPointer::parse(&other)
|
||||
}
|
||||
(key, Modification::Create(_)) => {
|
||||
set.response.failed(
|
||||
set.failed(
|
||||
modification,
|
||||
SetError::invalid_properties().with_property(key.into_owned()),
|
||||
);
|
||||
@@ -259,62 +295,205 @@ impl RegistrySet for Server {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial validations
|
||||
let is_create = matches!(modification, Modification::Create(_));
|
||||
if is_tenant_filtered || is_account_filtered {
|
||||
match ptr.last().and_then(|p| p.as_property_key()) {
|
||||
Some(Property::MemberTenantId) => {
|
||||
// SPDX-SnippetBegin
|
||||
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
// SPDX-License-Identifier: LicenseRef-SEL
|
||||
#[cfg(feature = "enterprise")]
|
||||
if access_token.tenant_id().is_some() {
|
||||
continue;
|
||||
}
|
||||
// SPDX-SnippetEnd
|
||||
|
||||
// SPDX-SnippetBegin
|
||||
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
// SPDX-License-Identifier: LicenseRef-SEL
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
if is_create
|
||||
&& object_type == ObjectType::Account
|
||||
&& self.core.is_enterprise_edition()
|
||||
&& !self.can_create_account().await?
|
||||
{
|
||||
set.response.failed(
|
||||
modification,
|
||||
SetError::forbidden().with_description(format!(
|
||||
"Enterprise licensed account limit reached: {} accounts licensed.",
|
||||
self.licensed_accounts()
|
||||
)),
|
||||
);
|
||||
continue 'outer;
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
continue;
|
||||
}
|
||||
Some(Property::AccountId) => {
|
||||
set.failed(
|
||||
modification,
|
||||
SetError::forbidden()
|
||||
.with_property(Property::AccountId)
|
||||
.with_description("Cannot change server-set property"),
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// SPDX-SnippetEnd
|
||||
|
||||
/*
|
||||
Principal creation:
|
||||
|
||||
- Add tenantId
|
||||
- Add default roles on account creation
|
||||
- Invalidate cache + logo cache
|
||||
- Validate effective permissions to grant access
|
||||
|
||||
Principal update:
|
||||
|
||||
- Remove tenantId, or return error
|
||||
- Invalidate cache + logo cache
|
||||
- Validate effective permissions to grant access
|
||||
|
||||
Principal deletion:
|
||||
|
||||
- Validate tenantId ownership
|
||||
- Invalidate cache
|
||||
- Schedule account deletion (if account)
|
||||
|
||||
*/
|
||||
|
||||
// Patch object
|
||||
if let Err(err) =
|
||||
object.patch(JsonPointerPatch::new(&ptr).with_create(is_create), value)
|
||||
match new_object
|
||||
.patch(JsonPointerPatch::new(&ptr).with_create(is_create), value)
|
||||
{
|
||||
Ok(MaybeUnpatched::Patched) => {}
|
||||
Ok(MaybeUnpatched::Unpatched { property, value }) => {
|
||||
unpatched_properties.append(property, value);
|
||||
}
|
||||
Ok(MaybeUnpatched::UnpatchedMany { properties }) => {
|
||||
if unpatched_properties.is_empty() {
|
||||
unpatched_properties = properties;
|
||||
} else {
|
||||
unpatched_properties.extend(properties);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
set.failed(modification, err.into());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_create {
|
||||
// Add tenantId for tenant filtered objects
|
||||
if is_tenant_filtered && let Some(tenant_id) = set.access_token.tenant_id()
|
||||
{
|
||||
new_object.inner.set_member_tenant_id(tenant_id.into());
|
||||
}
|
||||
|
||||
// Add accountId
|
||||
if has_account_id {
|
||||
new_object.inner.set_account_id(set.account_id.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate objects
|
||||
let result = match &mut new_object.inner {
|
||||
ObjectInner::Account(account) => {
|
||||
validate_account(&set, account, modification.as_account()).await?
|
||||
}
|
||||
ObjectInner::Role(role) => {
|
||||
validate_role(&set, role, modification.as_role()).await?
|
||||
}
|
||||
ObjectInner::MaskedEmail(masked_email) => {
|
||||
validate_masked_email(
|
||||
&set,
|
||||
masked_email,
|
||||
is_create,
|
||||
unpatched_properties,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ObjectInner::PublicKey(key) => {
|
||||
validate_public_key(
|
||||
&set,
|
||||
key,
|
||||
modification.as_public_key(),
|
||||
unpatched_properties,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ObjectInner::Domain(_) if is_create => {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxDomains).await?
|
||||
}
|
||||
ObjectInner::MailingList(_) if is_create => {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxMailingLists).await?
|
||||
}
|
||||
ObjectInner::OAuthClient(_) if is_create => {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxOauthClients).await?
|
||||
}
|
||||
_ => Ok(ObjectResponse::default()),
|
||||
};
|
||||
|
||||
let mut response = match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
set.failed(modification, err);
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
|
||||
// Save object
|
||||
let result = match &modification {
|
||||
Modification::Create(_) => {
|
||||
self.registry()
|
||||
.write(RegistryWrite::Insert {
|
||||
object: &new_object,
|
||||
id: response.id,
|
||||
})
|
||||
.await?
|
||||
}
|
||||
Modification::Update { id, object } => {
|
||||
if object.inner != new_object.inner {
|
||||
self.registry()
|
||||
.write(RegistryWrite::update(*id, &new_object, object))
|
||||
.await?
|
||||
} else {
|
||||
set.response.updated.append(*id, None);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match (modification, result) {
|
||||
(Modification::Update { id, object }, RegistryWriteResult::Success(_)) => {
|
||||
cache_invalidator.process_update(id, &object, &new_object);
|
||||
set.response.updated.append(
|
||||
id,
|
||||
if !response.object.is_empty() {
|
||||
Some(JmapValue::Object(response.object))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
}
|
||||
(Modification::Create(client_id), RegistryWriteResult::Success(id)) => {
|
||||
response.object.insert(Property::Id, RegistryValue::Id(id));
|
||||
set.response
|
||||
.created
|
||||
.insert(client_id, JmapValue::Object(response.object));
|
||||
}
|
||||
(Modification::Update { id, .. }, err) => {
|
||||
set.response.not_updated.append(id, map_write_error(err));
|
||||
}
|
||||
(Modification::Create(client_id), err) => {
|
||||
set.response
|
||||
.not_created
|
||||
.append(client_id, map_write_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process destroy
|
||||
for id in set.destroy {}
|
||||
for id in set.destroy.drain(..) {
|
||||
let object_id = ObjectId::new(object_type, id);
|
||||
if let Some(object) = self
|
||||
.registry()
|
||||
.get(object_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.filter(|object| {
|
||||
!(is_tenant_filtered
|
||||
&& access_token.tenant_id().map(Id::from)
|
||||
!= object.inner.member_tenant_id())
|
||||
|| (is_account_filtered
|
||||
&& object.inner.account_id() != Some(Id::from(set.account_id)))
|
||||
})
|
||||
{
|
||||
match self
|
||||
.registry()
|
||||
.write(RegistryWrite::Delete {
|
||||
object_id,
|
||||
object: Some(&object),
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RegistryWriteResult::Success(_) => {
|
||||
cache_invalidator.process_delete(id, &object);
|
||||
set.response.destroyed.push(id);
|
||||
}
|
||||
err => {
|
||||
set.response.not_destroyed.append(id, map_write_error(err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize cache invalidation
|
||||
self.invalidate_caches(cache_invalidator).await?;
|
||||
}
|
||||
ObjectType::QueuedMessage => {}
|
||||
ObjectType::Task => {}
|
||||
@@ -332,26 +511,131 @@ impl RegistrySet for Server {
|
||||
ObjectType::Credential => {}
|
||||
}
|
||||
|
||||
let todo = "read only properties";
|
||||
let todo = "password encryption";
|
||||
let todo = "management objects for actions (reload, etc)";
|
||||
// MaskedEmail: Generate masked email + Enforce count
|
||||
// DkimSignature = Generate keys + Enforce count?
|
||||
// PublicKey = Validate PK? Store decoded?
|
||||
// Schedule account and tenant deletions
|
||||
|
||||
todo!()
|
||||
// management objects for actions (reload, etc)";
|
||||
// DkimSignature = Generate keys + Enforce count?
|
||||
// PublicKey = Validate PK? Store decoded? Enforce count? Update ingest
|
||||
// Domain = trigger DNIM stuff
|
||||
// Validate expressions
|
||||
// Fallback admin password from env or files
|
||||
|
||||
Ok(set.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
trait SetModification {
|
||||
fn failed(&mut self, modification: Modification, error: SetError<Property>);
|
||||
}
|
||||
|
||||
impl SetModification for SetResponse<Registry> {
|
||||
impl RegistrySetResponse<'_> {
|
||||
fn failed(&mut self, modification: Modification, error: SetError<Property>) {
|
||||
match modification {
|
||||
Modification::Create(id) => self.not_created.append(id, error),
|
||||
Modification::Update(id) => self.not_updated.append(id, error),
|
||||
Modification::Create(id) => self.response.not_created.append(id, error),
|
||||
Modification::Update { id, .. } => self.response.not_updated.append(id, error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create(
|
||||
&mut self,
|
||||
client_id: String,
|
||||
result: RegistryWriteResult,
|
||||
mut object: Map<'static, Property, RegistryValue>,
|
||||
) {
|
||||
match result {
|
||||
RegistryWriteResult::Success(id) => {
|
||||
object.insert(Key::Property(Property::Id), RegistryValue::Id(id));
|
||||
self.response
|
||||
.created
|
||||
.insert(client_id, JmapValue::Object(object));
|
||||
}
|
||||
RegistryWriteResult::NotFound { .. } => {
|
||||
self.response
|
||||
.not_created
|
||||
.append(client_id, SetError::not_found());
|
||||
}
|
||||
err => {
|
||||
self.response
|
||||
.not_created
|
||||
.append(client_id, map_write_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, id: Id, result: RegistryWriteResult) {
|
||||
match result {
|
||||
RegistryWriteResult::Success(_) => self.response.updated.append(id, None),
|
||||
RegistryWriteResult::NotFound { .. } => {
|
||||
self.response.not_updated.append(id, SetError::not_found());
|
||||
}
|
||||
err => {
|
||||
self.response.not_updated.append(id, map_write_error(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_response(self) -> SetResponse<Registry> {
|
||||
self.response
|
||||
}
|
||||
}
|
||||
|
||||
impl Modification {
|
||||
fn as_account(&self) -> Option<&Account> {
|
||||
match self {
|
||||
Modification::Create(_) => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Account(account) => Some(account),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_role(&self) -> Option<&Role> {
|
||||
match self {
|
||||
Modification::Create(_) => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::Role(role) => Some(role),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn as_public_key(&self) -> Option<&PublicKey> {
|
||||
match self {
|
||||
Modification::Create(_) => None,
|
||||
Modification::Update { object, .. } => match &object.inner {
|
||||
ObjectInner::PublicKey(key) => Some(key),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_write_error(err: RegistryWriteResult) -> SetError<Property> {
|
||||
match err {
|
||||
RegistryWriteResult::CannotDeleteLinked {
|
||||
object_id,
|
||||
linked_objects,
|
||||
} => SetError::new(SetErrorType::ObjectIsLinked)
|
||||
.with_object_id(object_id)
|
||||
.with_linked_objects(linked_objects),
|
||||
RegistryWriteResult::InvalidSingletonId => SetError::invalid_properties()
|
||||
.with_property(Property::Id)
|
||||
.with_description("Invalid singleton id"),
|
||||
RegistryWriteResult::CannotDeleteSingleton => {
|
||||
SetError::forbidden().with_description("Singleton objects cannot be deleted")
|
||||
}
|
||||
RegistryWriteResult::InvalidForeignKey { object_id } => {
|
||||
SetError::new(SetErrorType::InvalidForeignKey).with_object_id(object_id)
|
||||
}
|
||||
RegistryWriteResult::PrimaryKeyConflict {
|
||||
property,
|
||||
existing_id,
|
||||
} => SetError::new(SetErrorType::PrimaryKeyViolation)
|
||||
.with_property(property)
|
||||
.with_object_id(existing_id),
|
||||
RegistryWriteResult::ValidationError { errors } => {
|
||||
SetError::new(SetErrorType::ValidationFailed).with_validation_errors(errors)
|
||||
}
|
||||
RegistryWriteResult::NotSupported => SetError::forbidden()
|
||||
.with_description("The requested action is not supported by the registry store"),
|
||||
RegistryWriteResult::NotFound { .. } => SetError::not_found(),
|
||||
RegistryWriteResult::Success(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user