JMAP Registry API implementation - part 6

This commit is contained in:
mdecimus
2026-03-01 18:30:04 +01:00
parent 07d5748f6b
commit 9ccc2ed6f0
74 changed files with 2417 additions and 785 deletions

View File

@@ -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)

View 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))
}

View File

@@ -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),
}
}
}

View 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
))
}

View 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!()
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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!(),
}
}