diff --git a/Cargo.lock b/Cargo.lock index b10abd43..f56e144f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6022,6 +6022,7 @@ dependencies = [ "mail-auth 0.8.0", "serde", "serde_json", + "tokio", "trc", "types", "utils", diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 199c2b71..902ffbfe 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -9,7 +9,7 @@ use crate::{ Server, auth::{ AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, FALLBACK_ADMIN_ID, Permissions, - PermissionsGroup, + PermissionsGroup, permissions::build_permissions_list, }, network::limiter::{ConcurrencyLimiter, LimiterResult}, }; @@ -17,7 +17,7 @@ use ahash::AHasher; use registry::{ schema::{ enums::Permission, - structs::{self, Account}, + structs::{self, Account, Roles}, }, types::EnumImpl, }; @@ -41,68 +41,17 @@ impl Server { ) -> trc::Result { match account { Account::User(account) => { - // Calculate effective permissions - let (mut permissions, roles) = match account.permissions { - structs::Permissions::Inherit => { - (PermissionsGroup::default(), account.role_ids.as_slice()) - } - structs::Permissions::Merge(permissions) => ( - PermissionsGroup::from(permissions), - account.role_ids.as_slice(), - ), - structs::Permissions::Replace(permissions) => { - (PermissionsGroup::from(permissions), &[][..]) - } - }; - if !roles.is_empty() { - permissions = self - .add_role_permissions(permissions, roles.iter().map(|v| v.id() as u32)) - .await - .caused_by(trc::location!())? - } - let tenant_id = account.member_tenant_id.map(|t| t.id() as u32); - - // SPDX-SnippetBegin - // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - // SPDX-License-Identifier: LicenseRef-SEL - - #[cfg(feature = "enterprise")] - { - if let Some(tenant_id) = tenant_id { - if self.is_enterprise_edition() { - // Limit tenant permissions - let tenant = - self.tenant(tenant_id).await.caused_by(trc::location!())?; - let (mut tenant_permissions, tenant_roles) = - if let Some(permissions) = &tenant.permissions { - if permissions.merge { - ((**permissions).clone(), tenant.id_roles.as_slice()) - } else { - ((**permissions).clone(), &[][..]) - } - } else { - (PermissionsGroup::default(), tenant.id_roles.as_slice()) - }; - if !tenant_roles.is_empty() { - tenant_permissions = self - .add_role_permissions( - tenant_permissions, - tenant_roles.iter().copied(), - ) - .await - .caused_by(trc::location!())? - } - - permissions.restrict(&tenant_permissions); - } else { - // Enterprise edition downgrade, remove any tenant administrator permissions - permissions.restrict(&PermissionsGroup::user()); - } - } - } - - // SPDX-SnippetEnd + let permissions = self + .effective_permissions( + &account.permissions, + account + .roles + .role_ids() + .unwrap_or(self.core.network.security.default_role_ids_user.as_slice()), + tenant_id, + ) + .await?; let can_impersonate = permissions.enabled.get(Permission::Impersonate as usize) && !permissions.disabled.get(Permission::Impersonate as usize); @@ -161,6 +110,7 @@ impl Server { } let now = now(); + let permissions = permissions.finalize(); let credential_scopes = account .credentials .into_iter() @@ -172,14 +122,17 @@ impl Server { .unwrap_or(u64::MAX); if expires_at > now { let permissions = match credential.permissions { - structs::Permissions::Inherit => permissions.clone().finalize(), + structs::Permissions::Inherit => permissions.clone(), structs::Permissions::Merge(merge) => { let mut permissions = permissions.clone(); - permissions.union(&PermissionsGroup::from(merge)); - permissions.finalize() + permissions.clear_many(&PermissionsGroup::from(merge).disabled); + permissions } structs::Permissions::Replace(replace) => { - PermissionsGroup::from(replace).finalize() + let mut replace_permissions = + PermissionsGroup::from(replace).finalize(); + replace_permissions.intersection(&permissions); + replace_permissions } }; Some(AccessScope { @@ -216,7 +169,7 @@ impl Server { tenant_id, member_of, access_to: access_to.into_boxed_slice(), - scopes: [AccessScope::new(permissions.finalize(), u32::MAX)] + scopes: [AccessScope::new(permissions, u32::MAX)] .into_iter() .chain(credential_scopes) .collect::>(), @@ -224,68 +177,16 @@ impl Server { .update_size()) } Account::Group(account) => { - // Calculate effective permissions - let (mut permissions, roles) = match account.permissions { - structs::Permissions::Inherit => { - (PermissionsGroup::default(), account.role_ids.as_slice()) - } - structs::Permissions::Merge(permissions) => ( - PermissionsGroup::from(permissions), - account.role_ids.as_slice(), - ), - structs::Permissions::Replace(permissions) => { - (PermissionsGroup::from(permissions), &[][..]) - } - }; - if !roles.is_empty() { - permissions = self - .add_role_permissions(permissions, roles.iter().map(|v| v.id() as u32)) - .await - .caused_by(trc::location!())? - } - let tenant_id = account.member_tenant_id.map(|t| t.id() as u32); - - // SPDX-SnippetBegin - // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - // SPDX-License-Identifier: LicenseRef-SEL - - #[cfg(feature = "enterprise")] - { - if let Some(tenant_id) = tenant_id { - if self.is_enterprise_edition() { - // Limit tenant permissions - let tenant = - self.tenant(tenant_id).await.caused_by(trc::location!())?; - let (mut tenant_permissions, tenant_roles) = - if let Some(permissions) = &tenant.permissions { - if permissions.merge { - ((**permissions).clone(), tenant.id_roles.as_slice()) - } else { - ((**permissions).clone(), &[][..]) - } - } else { - (PermissionsGroup::default(), tenant.id_roles.as_slice()) - }; - if !tenant_roles.is_empty() { - tenant_permissions = self - .add_role_permissions( - tenant_permissions, - tenant_roles.iter().copied(), - ) - .await - .caused_by(trc::location!())? - } - - permissions.restrict(&tenant_permissions); - } else { - // Enterprise edition downgrade, remove any tenant administrator permissions - permissions.restrict(&PermissionsGroup::user()); - } - } - } - - // SPDX-SnippetEnd + let permissions = self + .effective_permissions( + &account.permissions, + account.roles.role_ids().unwrap_or( + self.core.network.security.default_role_ids_group.as_slice(), + ), + tenant_id, + ) + .await?; Ok(AccessTokenInner { concurrent_imap_requests: self @@ -566,28 +467,20 @@ impl AccessToken { } pub fn permissions(&self) -> Vec { - const USIZE_BITS: usize = std::mem::size_of::() * 8; - const USIZE_MASK: u32 = USIZE_BITS as u32 - 1; - let mut permissions = Vec::new(); - - let Some(scope) = self.inner.scopes.get(self.scope_idx) else { - return permissions; - }; - - for (block_num, bytes) in scope.permissions.inner().iter().enumerate() { - let mut bytes = *bytes; - - while bytes != 0 { - let item = USIZE_MASK - bytes.leading_zeros(); - bytes ^= 1 << item; - if let Some(permission) = - Permission::from_id(((block_num * USIZE_BITS) + item as usize) as u16) - { - permissions.push(permission); - } - } + if let Some(scope) = self.inner.scopes.get(self.scope_idx) { + build_permissions_list(&scope.permissions) + } else { + vec![] } - permissions + } + + pub(crate) fn permissions_bits(&self) -> &Permissions { + &self + .inner + .scopes + .get(self.scope_idx) + .unwrap_or(&self.inner.scopes[0]) + .permissions } pub fn is_shared(&self, account_id: u32) -> bool { @@ -748,7 +641,12 @@ fn hash_account(account: &Account) -> u64 { match account { Account::User(account) => { account.member_tenant_id.hash(&mut s); - account.role_ids.hash(&mut s); + match &account.roles { + Roles::Default => {} + Roles::Custom(custom_roles) => { + custom_roles.role_ids.hash(&mut s); + } + } hash_permissions(&mut s, &account.permissions); for (credential_id, credential) in &account.credentials { let credential = credential.as_properties(); @@ -759,7 +657,12 @@ fn hash_account(account: &Account) -> u64 { } Account::Group(account) => { account.member_tenant_id.hash(&mut s); - account.role_ids.hash(&mut s); + match &account.roles { + Roles::Default => {} + Roles::Custom(custom_roles) => { + custom_roles.role_ids.hash(&mut s); + } + } hash_permissions(&mut s, &account.permissions); } } diff --git a/crates/common/src/auth/oauth/config.rs b/crates/common/src/auth/oauth/config.rs index f49fef87..58c7ebb5 100644 --- a/crates/common/src/auth/oauth/config.rs +++ b/crates/common/src/auth/oauth/config.rs @@ -17,11 +17,7 @@ use biscuit::{ }, jws::Secret, }; -use registry::schema::{ - enums::JwtSignatureAlgorithm, - prelude::{Object, ObjectType}, - structs::OidcProvider, -}; +use registry::schema::{enums::JwtSignatureAlgorithm, prelude::ObjectType, structs::OidcProvider}; use ring::signature::{self, KeyPair}; use rsa::{RsaPublicKey, pkcs1::DecodeRsaPublicKey, traits::PublicKeyParts}; use store::{ @@ -74,15 +70,24 @@ impl OAuthConfig { .collect::() .into_bytes(); + let signature_key = auth + .signature_key + .secret() + .await + .map_err(|err| { + bp.build_error(ObjectType::OidcProvider.singleton(), err); + }) + .unwrap_or_default(); + let (oidc_signing_secret, algorithm) = match oidc_signature_algorithm { SignatureAlgorithm::None | SignatureAlgorithm::HS256 | SignatureAlgorithm::HS384 | SignatureAlgorithm::HS512 => ( - Secret::Bytes(auth.signature_key.as_bytes().to_vec()), + Secret::Bytes(signature_key.as_bytes().to_vec()), AlgorithmParameters::OctetKey(OctetKeyParameters { key_type: OctetKeyType::Octet, - value: auth.signature_key.as_bytes().to_vec(), + value: signature_key.as_bytes().to_vec(), }), ), SignatureAlgorithm::RS256 @@ -91,6 +96,7 @@ impl OAuthConfig { | SignatureAlgorithm::PS256 | SignatureAlgorithm::PS384 | SignatureAlgorithm::PS512 => parse_rsa_key(&auth) + .await .map_err(|err| { bp.build_error(ObjectType::OidcProvider.singleton(), err); }) @@ -105,6 +111,7 @@ impl OAuthConfig { }), SignatureAlgorithm::ES256 | SignatureAlgorithm::ES384 | SignatureAlgorithm::ES512 => { parse_ecdsa_key(&auth, oidc_signature_algorithm) + .await .map_err(|err| { bp.build_error(ObjectType::OidcProvider.singleton(), err); }) @@ -139,7 +146,13 @@ impl OAuthConfig { }; OAuthConfig { - oauth_key: auth.encryption_key, + oauth_key: auth + .encryption_key + .secret() + .await + .map_err(|err| bp.build_error(ObjectType::OidcProvider.singleton(), err)) + .unwrap_or_default() + .into_owned(), oauth_expiry_user_code: auth.user_code_expiry.as_secs(), oauth_expiry_auth_code: auth.auth_code_expiry.as_secs(), oauth_expiry_token: auth.access_token_expiry.as_secs(), @@ -181,8 +194,8 @@ impl Default for OAuthConfig { } } -fn parse_rsa_key(auth: &OidcProvider) -> Result<(Secret, AlgorithmParameters), String> { - let rsa_key_pair = build_rsa_keypair(&auth.signature_key)?; +async fn parse_rsa_key(auth: &OidcProvider) -> Result<(Secret, AlgorithmParameters), String> { + let rsa_key_pair = build_rsa_keypair(auth.signature_key.secret().await?.as_ref())?; let rsa_public_key = match RsaPublicKey::from_pkcs1_der(rsa_key_pair.public_key().as_ref()) { Ok(key) => key, @@ -204,7 +217,7 @@ fn parse_rsa_key(auth: &OidcProvider) -> Result<(Secret, AlgorithmParameters), S )) } -fn parse_ecdsa_key( +async fn parse_ecdsa_key( auth: &OidcProvider, oidc_signature_algorithm: SignatureAlgorithm, ) -> Result<(Secret, AlgorithmParameters), String> { @@ -220,7 +233,7 @@ fn parse_ecdsa_key( _ => unreachable!(), }; - let ecdsa_key_pair = build_ecdsa_pem(alg, &auth.signature_key)?; + let ecdsa_key_pair = build_ecdsa_pem(alg, auth.signature_key.secret().await?.as_ref())?; let ecdsa_public_key = ecdsa_key_pair.public_key().as_ref(); let (x, y) = match oidc_signature_algorithm { diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 8c8ebc24..ef37226a 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -6,11 +6,19 @@ use crate::{ Server, - auth::{Permissions, PermissionsGroup}, + auth::{AccessToken, Permissions, PermissionsGroup}, }; use ahash::AHashSet; -use registry::schema::{enums::Permission, structs::PermissionsList}; +use registry::{ + schema::{ + enums::Permission, + structs::{self, Account, PermissionsList}, + }, + types::EnumImpl, +}; use trc::AddContext; +use types::id::Id; +use utils::map::vec_map::VecMap; impl Server { pub async fn add_role_permissions( @@ -32,6 +40,133 @@ impl Server { Ok(base_permissions) } + + pub async fn effective_permissions( + &self, + permissions: &structs::Permissions, + role_ids: &[Id], + tenant_id: Option, + ) -> trc::Result { + // Calculate effective permissions + let (mut permissions, roles) = match permissions { + structs::Permissions::Inherit => (PermissionsGroup::default(), role_ids), + structs::Permissions::Merge(permissions) => { + (PermissionsGroup::from(permissions), role_ids) + } + structs::Permissions::Replace(permissions) => { + (PermissionsGroup::from(permissions), &[][..]) + } + }; + if !roles.is_empty() { + permissions = self + .add_role_permissions(permissions, roles.iter().map(|v| v.id() as u32)) + .await + .caused_by(trc::location!())? + } + + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + + #[cfg(feature = "enterprise")] + { + if let Some(tenant_id) = tenant_id { + if self.is_enterprise_edition() { + // Limit tenant permissions + let tenant = self.tenant(tenant_id).await.caused_by(trc::location!())?; + let (mut tenant_permissions, tenant_roles) = + if let Some(permissions) = &tenant.permissions { + if permissions.merge { + ((**permissions).clone(), tenant.id_roles.as_slice()) + } else { + ((**permissions).clone(), &[][..]) + } + } else { + (PermissionsGroup::default(), tenant.id_roles.as_slice()) + }; + if !tenant_roles.is_empty() { + tenant_permissions = self + .add_role_permissions(tenant_permissions, tenant_roles.iter().copied()) + .await + .caused_by(trc::location!())? + } + + permissions.restrict(&tenant_permissions); + } else { + // Enterprise edition downgrade, remove any tenant administrator permissions + permissions.restrict(&PermissionsGroup::user()); + } + } + } + + // SPDX-SnippetEnd + + Ok(permissions) + } + + pub async fn can_set_permissions( + &self, + access_token: &AccessToken, + account: &Account, + ) -> trc::Result>> { + let (permissions, role_ids, tenant_id) = match account { + Account::User(account) => ( + &account.permissions, + account + .roles + .role_ids() + .unwrap_or(self.core.network.security.default_role_ids_user.as_slice()), + account.member_tenant_id.map(|t| t.document_id()), + ), + Account::Group(account) => ( + &account.permissions, + account + .roles + .role_ids() + .unwrap_or(self.core.network.security.default_role_ids_group.as_slice()), + account.member_tenant_id.map(|t| t.document_id()), + ), + }; + + self.effective_permissions(permissions, role_ids, tenant_id) + .await + .map(|permissions| access_token.can_grant_permissions(permissions.finalize())) + } +} + +impl AccessToken { + pub fn can_grant_permissions( + &self, + mut requested_permissions: Permissions, + ) -> Result<(), Vec> { + requested_permissions.difference(self.permissions_bits()); + if requested_permissions.is_empty() { + Ok(()) + } else { + Err(build_permissions_list(&requested_permissions)) + } + } +} + +pub(crate) fn build_permissions_list(permissions_in: &Permissions) -> Vec { + const USIZE_BITS: usize = std::mem::size_of::() * 8; + const USIZE_MASK: u32 = USIZE_BITS as u32 - 1; + let mut permissions = Vec::new(); + + for (block_num, bytes) in permissions_in.inner().iter().enumerate() { + let mut bytes = *bytes; + + while bytes != 0 { + let item = USIZE_MASK - bytes.leading_zeros(); + bytes ^= 1 << item; + if let Some(permission) = + Permission::from_id(((block_num * USIZE_BITS) + item as usize) as u16) + { + permissions.push(permission); + } + } + } + permissions } impl PermissionsGroup { @@ -253,12 +388,24 @@ impl PermissionsGroup { impl From for PermissionsGroup { fn from(value: PermissionsList) -> Self { + PermissionsGroup::from(&value.permissions) + } +} + +impl From<&PermissionsList> for PermissionsGroup { + fn from(value: &PermissionsList) -> Self { + PermissionsGroup::from(&value.permissions) + } +} + +impl From<&VecMap> for PermissionsGroup { + fn from(value: &VecMap) -> Self { let mut permissions = PermissionsGroup::default(); - for (permission, is_set) in value.permissions { - if is_set { - permissions.enabled.set(permission as usize); + for (permission, is_set) in value { + if *is_set { + permissions.enabled.set(*permission as usize); } else { - permissions.disabled.set(permission as usize); + permissions.disabled.set(*permission as usize); } } permissions diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index 3b0aa556..26851284 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -10,7 +10,7 @@ use crate::{Server, auth::DomainCache}; use registry::{ schema::{ prelude::{Object, ObjectType}, - structs::{Account, EmailAlias, GroupAccount, UserAccount}, + structs::{Account, EmailAlias, GroupAccount, Roles, UserAccount}, }, types::{datetime::UTCDateTime, id::ObjectId}, }; @@ -178,11 +178,22 @@ impl Server { description: account.description, member_group_ids, member_tenant_id: domain.id_tenant.map(Id::from), - role_ids: self.core.network.security.default_role_ids_user.clone(), + roles: Roles::Default, secret: account.secret.unwrap_or_default(), ..Default::default() })); + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] + if self.core.is_enterprise_edition() && !self.can_create_account().await? { + return Err(trc::AuthEvent::Error.into_err().details( + "Account creation not possible: license key account limit reached", + )); + } + // SPDX-SnippetEnd + match self .registry() .write(RegistryWrite::insert(&account)) @@ -306,10 +317,21 @@ impl Server { created_at: UTCDateTime::now(), description: group.description, member_tenant_id: domain.id_tenant.map(Id::from), - role_ids: self.core.network.security.default_role_ids_group.clone(), + roles: Roles::Default, ..Default::default() })); + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] + if self.core.is_enterprise_edition() && !self.can_create_account().await? { + return Err(trc::AuthEvent::Error.into_err().details( + "Account creation not possible: license key account limit reached", + )); + } + // SPDX-SnippetEnd + match self .registry() .write(RegistryWrite::insert(&account)) diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index 66029539..c51b3a5b 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -9,12 +9,260 @@ use crate::{ auth::EmailCache, ipc::{BroadcastEvent, CacheInvalidation}, }; +use ahash::AHashSet; +use registry::{ + schema::{ + prelude::{Object, ObjectInner, ObjectType}, + structs::Account, + }, + types::id::ObjectId, +}; +use store::{registry::RegistryQuery, roaring::RoaringBitmap}; +use types::id::Id; + +#[derive(Debug, Default)] +pub struct CacheInvalidationBuilder { + changes: AHashSet, +} + +impl CacheInvalidationBuilder { + pub fn process_update(&mut self, id: Id, current_object: &Object, new_object: &Object) { + let id = id.document_id(); + match (¤t_object.inner, &new_object.inner) { + ( + ObjectInner::Account(Account::User(current)), + ObjectInner::Account(Account::User(new)), + ) => { + let was_renamed = + (current.name != new.name) || (current.domain_id != new.domain_id); + let quota_changed = current.quotas != new.quotas; + let permissions_changed = current.permissions != new.permissions; + let roles_changed = current.roles != new.roles; + let tenant_changed = current.member_tenant_id != new.member_tenant_id; + let details_changed = + current.locale != new.locale || current.description != new.description; + let groups_changed = current.member_group_ids != new.member_group_ids; + let aliases_changed = current.aliases != new.aliases; + let credentials_changed = current.credentials != new.credentials + || current.secret != new.secret + || current.otp_auth != new.otp_auth; + + if was_renamed + || aliases_changed + || tenant_changed + || groups_changed + || quota_changed + || details_changed + { + self.invalidate(CacheInvalidation::Account(id)); + } + + if tenant_changed + || groups_changed + || credentials_changed + || roles_changed + || permissions_changed + { + self.invalidate(CacheInvalidation::AccessToken(id)); + } + + if was_renamed { + self.invalidate(CacheInvalidation::DavResources(id)); + } + } + + ( + ObjectInner::Account(Account::Group(current)), + ObjectInner::Account(Account::Group(new)), + ) => { + let was_renamed = + (current.name != new.name) || (current.domain_id != new.domain_id); + let quota_changed = current.quotas != new.quotas; + let permissions_changed = current.permissions != new.permissions; + let roles_changed = current.roles != new.roles; + let tenant_changed = current.member_tenant_id != new.member_tenant_id; + let details_changed = + current.locale != new.locale || current.description != new.description; + let aliases_changed = current.aliases != new.aliases; + + if was_renamed + || aliases_changed + || tenant_changed + || quota_changed + || details_changed + { + self.invalidate(CacheInvalidation::Account(id)); + } + + if tenant_changed || roles_changed || permissions_changed { + self.invalidate(CacheInvalidation::AccessToken(id)); + } + + if was_renamed { + self.invalidate(CacheInvalidation::DavResources(id)); + } + } + + (ObjectInner::Domain(current), ObjectInner::Domain(new)) => { + if (current.name != new.name) + || (current.directory_id != new.directory_id) + || (current.member_tenant_id != new.member_tenant_id) + || (current.catch_all_address != new.catch_all_address) + || (current.sub_addressing != new.sub_addressing) + || (current.allow_relaying != new.allow_relaying) + || (current.is_enabled != new.is_enabled) + { + self.invalidate(CacheInvalidation::Domain(id)); + } + + if current.logo != new.logo { + self.invalidate(CacheInvalidation::DomainLogo(id)); + } + } + + (ObjectInner::DkimSignature(_), ObjectInner::DkimSignature(_)) => { + self.invalidate(CacheInvalidation::DkimSignature(id)); + } + + (ObjectInner::Tenant(current), ObjectInner::Tenant(new)) => { + if (current.permissions != new.permissions) + || (current.roles != new.roles) + || (current.quotas != new.quotas) + { + self.invalidate(CacheInvalidation::Tenant(id)); + } + + if current.logo != new.logo { + self.invalidate(CacheInvalidation::TenantLogo(id)); + } + } + + (ObjectInner::Role(current), ObjectInner::Role(new)) => { + if (current.permissions != new.permissions) + || (current.member_tenant_id != new.member_tenant_id) + || (current.role_ids != new.role_ids) + { + self.invalidate(CacheInvalidation::Role(id)); + } + } + + (ObjectInner::MailingList(current), ObjectInner::MailingList(new)) => { + if (current.aliases != new.aliases) + || (current.name != new.name) + || (current.recipients != new.recipients) + || (current.domain_id != new.domain_id) + { + self.invalidate(CacheInvalidation::List(id)); + } + } + _ => {} + } + } + + pub fn process_delete(&mut self, id: Id, object: &Object) { + let id = id.document_id(); + match &object.inner { + ObjectInner::Account(_) => { + self.invalidate(CacheInvalidation::AccessToken(id)); + self.invalidate(CacheInvalidation::Account(id)); + self.invalidate(CacheInvalidation::DavResources(id)); + } + ObjectInner::Domain(_) => { + self.invalidate(CacheInvalidation::Domain(id)); + self.invalidate(CacheInvalidation::DomainLogo(id)); + } + ObjectInner::DkimSignature(_) => { + self.invalidate(CacheInvalidation::DkimSignature(id)); + } + ObjectInner::Tenant(_) => { + self.invalidate(CacheInvalidation::Tenant(id)); + self.invalidate(CacheInvalidation::TenantLogo(id)); + } + ObjectInner::Role(_) => { + self.invalidate(CacheInvalidation::Role(id)); + } + ObjectInner::MailingList(_) => { + self.invalidate(CacheInvalidation::List(id)); + } + _ => {} + } + } + + pub fn invalidate(&mut self, change: CacheInvalidation) { + self.changes.insert(change); + } +} impl Server { - pub async fn invalidate_caches(&self, changes: Vec, broadcast: bool) { + pub async fn invalidate_caches(&self, changes: CacheInvalidationBuilder) -> trc::Result<()> { + let mut changes = changes.changes; + if changes.is_empty() { + return Ok(()); + } + + // Invalidate objects linking roles + let mut role_ids = changes + .iter() + .filter_map(|change| { + if let CacheInvalidation::Role(role_id) = change { + Some(*role_id) + } else { + None + } + }) + .collect::>(); + if !role_ids.is_empty() { + let mut fetched_role_ids = AHashSet::new(); + + while let Some(role_id) = role_ids.pop() { + if fetched_role_ids.insert(role_id) { + let linked_objects = self + .registry() + .linked_objects(ObjectId::new(ObjectType::Role, role_id.into())) + .await?; + for linked_object in linked_objects { + match linked_object.object() { + ObjectType::Account => { + changes.insert(CacheInvalidation::AccessToken( + linked_object.id().document_id(), + )); + } + ObjectType::Tenant => { + // Invalidate all accounts of the tenant + let tenant_id = linked_object.id().document_id(); + changes.insert(CacheInvalidation::Tenant(tenant_id)); + for account_id in self + .registry() + .query::( + RegistryQuery::new(ObjectType::Account) + .with_tenant(tenant_id.into()), + ) + .await? + { + changes.insert(CacheInvalidation::AccessToken(account_id)); + } + } + ObjectType::Role => { + role_ids.push(linked_object.id().document_id()); + } + _ => {} + } + } + } + } + } + + let changes = changes.into_iter().collect::>(); + self.invalidate_local_caches(&changes).await; + self.cluster_broadcast(BroadcastEvent::CacheInvalidation(changes)) + .await; + Ok(()) + } + + pub async fn invalidate_local_caches(&self, changes: &[CacheInvalidation]) { let cache = &self.inner.cache; - for change in &changes { + for change in changes { match change { CacheInvalidation::AccessToken(id) => { cache.access_tokens.remove(id); @@ -51,13 +299,29 @@ impl Server { |_, v| !matches!(v, EmailCache::MailingList(list_id) if list_id == id), ); } + CacheInvalidation::DomainLogo(id) => { + self.inner + .data + .logos + .lock() + .retain(|_, v| v.domain_id != *id); + } + CacheInvalidation::TenantLogo(id) => { + self.inner + .data + .logos + .lock() + .retain(|_, v| v.tenant_id != Some(*id)); + } } } - - // Broadcast cache invalidation to other servers - if broadcast { - self.cluster_broadcast(BroadcastEvent::CacheInvalidation(changes)) - .await; - } + } +} + +impl From for CacheInvalidationBuilder { + fn from(invalidation: CacheInvalidation) -> Self { + let mut builder = CacheInvalidationBuilder::default(); + builder.invalidate(invalidation); + builder } } diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index 377e1f08..500197b7 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -551,8 +551,16 @@ impl Server { let cache = Arc::new(TenantCache { id_roles: tenant - .role_ids - .into_iter() + .roles + .role_ids() + .unwrap_or( + self.core + .network + .security + .default_role_ids_tenant + .as_slice(), + ) + .iter() .map(|id| id.document_id()) .collect(), quota_disk, diff --git a/crates/common/src/config/mailstore/spamfilter.rs b/crates/common/src/config/mailstore/spamfilter.rs index dd7ab967..2f63d71a 100644 --- a/crates/common/src/config/mailstore/spamfilter.rs +++ b/crates/common/src/config/mailstore/spamfilter.rs @@ -196,9 +196,9 @@ impl SpamFilterConfig { pyzor: PyzorConfig::parse(bp).await, classifier: ClassifierConfig::parse(bp).await, scores: SpamFilterScoreConfig { - reject_threshold: spam.score_reject as f32, - discard_threshold: spam.score_discard as f32, - spam_threshold: spam.score_spam as f32, + reject_threshold: spam.score_reject.into_inner() as f32, + discard_threshold: spam.score_discard.into_inner() as f32, + spam_threshold: spam.score_spam.into_inner() as f32, }, grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), } @@ -375,9 +375,10 @@ impl SpamFilterLists { for tag in bp.list_infallible::().await { match tag.object { - SpamTag::Score(tag) => lists - .scores - .insert_pattern(&tag.tag, SpamFilterAction::Allow(tag.score as f32)), + SpamTag::Score(tag) => lists.scores.insert_pattern( + &tag.tag, + SpamFilterAction::Allow(tag.score.into_inner() as f32), + ), SpamTag::Discard(tag) => lists .scores .insert_pattern(&tag.tag, SpamFilterAction::Discard), @@ -440,7 +441,7 @@ impl PyzorConfig { timeout: pyzor.timeout.into_inner(), min_count: pyzor.block_count, min_wl_count: pyzor.allow_count, - ratio: pyzor.ratio, + ratio: pyzor.ratio.into_inner(), } .into() } @@ -508,10 +509,10 @@ impl FtrlParameters { }; FtrlParameters { feature_hash_size: 1 << hash_size, - alpha: params.alpha, - beta: params.beta, - l1_ratio: params.l1_ratio, - l2_ratio: params.l2_ratio, + alpha: params.alpha.into_inner(), + beta: params.beta.into_inner(), + l1_ratio: params.l1_ratio.into_inner(), + l2_ratio: params.l2_ratio.into_inner(), } } } diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index 32cf5368..d1ce8586 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -389,6 +389,7 @@ impl AsnGeoLookupConfig { headers: asn .http_auth .build_headers(asn.http_headers, None) + .await .map_err(|err| { bp.build_error( ObjectType::Asn.singleton(), diff --git a/crates/common/src/config/server/tls.rs b/crates/common/src/config/server/tls.rs index 8d214ca6..9bee9bdd 100644 --- a/crates/common/src/config/server/tls.rs +++ b/crates/common/src/config/server/tls.rs @@ -88,7 +88,18 @@ impl Server { )), }, server.key_name, - server.key, + server + .key + .secret() + .await + .map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain TSIG key secret") + .id(id.to_string()) + })? + .into_owned() + .into_bytes(), match server.tsig_algorithm { enums::TsigAlgorithm::HmacMd5 => TsigAlgorithm::HmacMd5, enums::TsigAlgorithm::Gss => TsigAlgorithm::Gss, @@ -118,7 +129,17 @@ impl Server { enums::Sig0Algorithm::EcdsaP256Sha256 => KeyPair::ECDSA( EcdsaKeyPair::from_pkcs8( &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING, - server.key.as_bytes(), + server + .key + .secret() + .await + .map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })? + .as_bytes(), &ring::rand::SystemRandom::new(), ) .map_err(|err| { @@ -131,7 +152,17 @@ impl Server { enums::Sig0Algorithm::EcdsaP384Sha384 => KeyPair::ECDSA( EcdsaKeyPair::from_pkcs8( &ring::signature::ECDSA_P384_SHA384_ASN1_SIGNING, - server.key.as_bytes(), + server + .key + .secret() + .await + .map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })? + .as_bytes(), &ring::rand::SystemRandom::new(), ) .map_err(|err| { @@ -142,7 +173,20 @@ impl Server { })?, ), enums::Sig0Algorithm::Ed25519 => KeyPair::ED25519( - Ed25519KeyPair::from_pkcs8(server.key.as_bytes()).map_err(|err| { + Ed25519KeyPair::from_pkcs8( + server + .key + .secret() + .await + .map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })? + .as_bytes(), + ) + .map_err(|err| { trc::DnsEvent::BuildError .reason(err) .details("Failed to build Ed25519 key pair") @@ -158,20 +202,47 @@ impl Server { }, ), DnsServer::Cloudflare(server) => DnsUpdater::new_cloudflare( - server.secret, + server.secret.secret().await.map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })?, server.email, server.timeout.into_inner().into(), ), - DnsServer::DigitalOcean(server) => { - DnsUpdater::new_digitalocean(server.secret, server.timeout.into_inner().into()) - } - DnsServer::DeSEC(server) => { - DnsUpdater::new_desec(server.secret, server.timeout.into_inner().into()) - } + DnsServer::DigitalOcean(server) => DnsUpdater::new_digitalocean( + server.secret.secret().await.map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })?, + server.timeout.into_inner().into(), + ), + DnsServer::DeSEC(server) => DnsUpdater::new_desec( + server.secret.secret().await.map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain key secret") + .id(id.to_string()) + })?, + server.timeout.into_inner().into(), + ), DnsServer::Ovh(server) => DnsUpdater::new_ovh( server.application_key, - server.application_secret, - server.consumer_key, + server.application_secret.secret().await.map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain application secret") + .id(id.to_string()) + })?, + server.consumer_key.secret().await.map_err(|err| { + trc::DnsEvent::BuildError + .reason(err) + .details("Failed to obtain consumer key") + .id(id.to_string()) + })?, match server.ovh_endpoint { enums::OvhEndpoint::OvhEu => OvhEndpoint::OvhEu, enums::OvhEndpoint::OvhCa => OvhEndpoint::OvhCa, @@ -199,10 +270,18 @@ pub(crate) async fn parse_certificates( ) { // Parse certificates for cert_obj in bp.list_infallible::().await { - match build_certified_key( - cert_obj.object.certificate.into_bytes(), - cert_obj.object.private_key.into_bytes(), - ) { + let secret = match cert_obj.object.private_key.secret().await { + Ok(secret) => secret.into_owned().into_bytes(), + Err(err) => { + bp.build_error( + cert_obj.id, + format!("Failed to obtain private key secret: {err}"), + ); + continue; + } + }; + + match build_certified_key(cert_obj.object.certificate.into_bytes(), secret) { Ok(cert) => { match cert .end_entity_cert() diff --git a/crates/common/src/config/smtp/queue.rs b/crates/common/src/config/smtp/queue.rs index f430e5ef..14e07359 100644 --- a/crates/common/src/config/smtp/queue.rs +++ b/crates/common/src/config/smtp/queue.rs @@ -340,6 +340,14 @@ impl QueueConfig { ); } MtaRoute::Relay(route) => { + let secret = route + .auth_secret + .secret() + .await + .map_err(|err| { + bp.build_error(obj.id, err); + }) + .unwrap_or_default(); queue.routing_strategy.insert( route.name, RoutingStrategy::Relay(RelayConfig { @@ -351,8 +359,8 @@ impl QueueConfig { }, auth: route .auth_username - .and_then(|user| route.auth_secret.map(|secret| (user, secret))) - .map(|(user, secret)| Credentials::new(user, secret)), + .and_then(|user| secret.map(|secret| (user, secret))) + .map(|(user, secret)| Credentials::new(user, secret.into_owned())), tls_implicit: route.implicit_tls, tls_allow_invalid_certs: route.allow_invalid_certs, }), diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index fb400adb..fabd9941 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -184,6 +184,37 @@ impl SessionConfig { let data = bp.setting_infallible::().await; let ext = bp.setting_infallible::().await; + let mut hooks = Vec::new(); + + for hook in bp.list_infallible::().await { + let id = hook.id; + let hook = hook.object; + let enable = bp.compile_expr(id, &hook.ctx_enable()); + let headers = match hook + .http_auth + .build_headers(hook.http_headers, "application/json".into()) + .await + { + Ok(headers) => headers, + Err(err) => { + bp.build_error(id, format!("Unable to build HTTP headers: {}", err)); + continue; + } + }; + + hooks.push(MTAHook { + enable, + id, + url: hook.url, + timeout: hook.timeout.into_inner(), + headers, + tls_allow_invalid_certs: hook.allow_invalid_certs, + tempfail_on_error: hook.temp_fail_on_error, + run_on_stage: hook.stages.into_iter().map(Stage::from).collect(), + max_response_size: hook.max_response_size as usize, + }); + } + SessionConfig { timeout: bp.compile_expr( ObjectType::MtaInboundSession.singleton(), @@ -381,33 +412,7 @@ impl SessionConfig { }) }) .collect(), - hooks: bp - .list_infallible::() - .await - .into_iter() - .filter_map(|hook| { - let id = hook.id; - let hook = hook.object; - - Some(MTAHook { - enable: bp.compile_expr(id, &hook.ctx_enable()), - id, - url: hook.url, - timeout: hook.timeout.into_inner(), - headers: hook - .http_auth - .build_headers(hook.http_headers, "application/json".into()) - .map_err(|err| { - bp.build_error(id, format!("Unable to build HTTP headers: {}", err)) - }) - .ok()?, - tls_allow_invalid_certs: hook.allow_invalid_certs, - tempfail_on_error: hook.temp_fail_on_error, - run_on_stage: hook.stages.into_iter().map(Stage::from).collect(), - max_response_size: hook.max_response_size as usize, - }) - }) - .collect(), + hooks, } } } diff --git a/crates/common/src/config/telemetry.rs b/crates/common/src/config/telemetry.rs index 37e5735b..aa3360f3 100644 --- a/crates/common/src/config/telemetry.rs +++ b/crates/common/src/config/telemetry.rs @@ -20,7 +20,7 @@ use opentelemetry_sdk::{ use opentelemetry_semantic_conventions::resource::SERVICE_VERSION; use registry::schema::{ enums::{EventPolicy, LogRotateFrequency}, - prelude::{Object, ObjectType}, + prelude::ObjectType, structs::{self, EventTracingLevel, MetricsPrometheus, Tracer, WebHook}, }; use std::{collections::HashMap, sync::Arc, time::Duration}; @@ -261,7 +261,11 @@ impl Tracers { events = tracer.events; events_policy = tracer.events_policy; - let headers = match tracer.http_auth.build_headers(tracer.http_headers, None) { + let headers = match tracer + .http_auth + .build_headers(tracer.http_headers, None) + .await + { Ok(headers) => headers .into_iter() .filter_map(|(k, v)| { @@ -460,6 +464,7 @@ impl Tracers { let headers = match hook .http_auth .build_headers(hook.http_headers, "application/json".into()) + .await { Ok(headers) => headers, Err(err) => { @@ -478,7 +483,19 @@ impl Tracers { timeout: hook.timeout.into_inner(), tls_allow_invalid_certs: hook.allow_invalid_certs, headers, - key: hook.signature_key.unwrap_or_default(), + key: hook + .signature_key + .secret() + .await + .map_err(|err| { + bp.build_error( + id, + format!("Unable to retrieve signature key: {}", err), + ); + }) + .unwrap_or_default() + .unwrap_or_default() + .into_owned(), throttle: hook.throttle.into_inner(), discard_after: hook.discard_after.into_inner(), }), @@ -545,17 +562,30 @@ impl Metrics { Metrics { prometheus: match metrics.prometheus { - MetricsPrometheus::Enabled(prom) => Some(PrometheusMetrics { - auth: prom.auth_username.and_then(|user| { - prom.auth_secret - .map(|secret| STANDARD.encode(format!("{user}:{secret}"))) - }), - }), + MetricsPrometheus::Enabled(prom) => { + let secret = prom + .auth_secret + .secret() + .await + .map_err(|err| { + bp.build_error( + ObjectType::Metrics.singleton(), + format!("Unable to retrieve Prometheus auth secret: {err}"), + ); + }) + .unwrap_or_default(); + Some(PrometheusMetrics { + auth: prom.auth_username.and_then(|user| { + secret.map(|secret| STANDARD.encode(format!("{user}:{secret}"))) + }), + }) + } MetricsPrometheus::Disabled => None, }, otel: match metrics.open_telemetry { structs::MetricsOtel::Http(otel) => { - let headers = match otel.http_auth.build_headers(otel.http_headers, None) { + let headers = match otel.http_auth.build_headers(otel.http_headers, None).await + { Ok(headers) => headers .into_iter() .filter_map(|(k, v)| { diff --git a/crates/common/src/enterprise/config.rs b/crates/common/src/enterprise/config.rs index e15c88e1..8a8288e9 100644 --- a/crates/common/src/enterprise/config.rs +++ b/crates/common/src/enterprise/config.rs @@ -17,7 +17,10 @@ use ahash::AHashMap; use registry::schema::{ enums::AiModelType, prelude::{ObjectType, Property}, - structs::{self, AiModel, Alert, CalendarAlarm, CalendarScheduling, DataRetention, SpamLlm}, + structs::{ + self, AiModel, Alert, CalendarAlarm, CalendarScheduling, DataRetention, SecretKeyOptional, + SecretKeyValue, SpamLlm, + }, }; use std::sync::Arc; use store::{ @@ -33,14 +36,17 @@ impl Enterprise { let mut update_license = None; let mut enterprise = bp.setting_infallible::().await; - let license_result = match (&enterprise.license_key, &enterprise.api_key) { - (Some(license_key), maybe_api_key) => { + let license_result = match ( + enterprise.license_key.secret().await, + enterprise.api_key.secret().await, + ) { + (Ok(Some(license_key)), Ok(maybe_api_key)) => { match ( LicenseKey::new(license_key, &server_hostname), maybe_api_key, ) { (Ok(license), Some(api_key)) if license.is_near_expiration() => Ok(license - .try_renew(api_key) + .try_renew(api_key.as_ref()) .await .map(|result| { update_license = Some(result.encoded_key); @@ -49,7 +55,7 @@ impl Enterprise { .unwrap_or(license)), (Ok(license), None) => Ok(license), (Err(_), Some(api_key)) => LicenseKey::invalid(&server_hostname) - .try_renew(api_key) + .try_renew(api_key.as_ref()) .await .map(|result| { update_license = Some(result.encoded_key); @@ -58,14 +64,22 @@ impl Enterprise { (maybe_license, _) => maybe_license, } } - (None, Some(api_key)) => LicenseKey::invalid(&server_hostname) - .try_renew(api_key) + (Ok(None), Ok(Some(api_key))) => LicenseKey::invalid(&server_hostname) + .try_renew(api_key.as_ref()) .await .map(|result| { update_license = Some(result.encoded_key); result.key }), - (None, None) => { + (Ok(None), Ok(None)) => { + return None; + } + (Err(err), _) => { + bp.build_error(ObjectType::Enterprise.singleton(), err); + return None; + } + (_, Err(err)) => { + bp.build_error(ObjectType::Enterprise.singleton(), err); return None; } }; @@ -82,7 +96,7 @@ impl Enterprise { // Update the license if a new one was obtained let logo_url = enterprise.logo_url.clone(); if let Some(license) = update_license { - enterprise.license_key = Some(license); + enterprise.license_key = SecretKeyOptional::Value(SecretKeyValue { secret: license }); if let Err(err) = bp .registry .write(RegistryWrite::insert(&enterprise.into())) @@ -139,6 +153,7 @@ impl Enterprise { headers: api .http_auth .build_headers(api.http_headers, "application/json".into()) + .await .map_err(|err| { bp.build_error(id, format!("Unable to build HTTP headers: {}", err)) }) @@ -146,7 +161,7 @@ impl Enterprise { model: api.model, timeout: api.timeout.into_inner(), tls_allow_invalid_certs: api.allow_invalid_certs, - default_temperature: api.temperature, + default_temperature: api.temperature.into_inner(), }); ai_apis.insert(api.id.clone(), api.clone()); ai_apis_ids.insert(id.id().id(), api); @@ -254,7 +269,7 @@ impl SpamFilterLlmConfig { }; Some(SpamFilterLlmConfig { model, - temperature: llm.temperature, + temperature: llm.temperature.into_inner(), prompt: llm.prompt, separator: llm.separator.chars().next().unwrap_or(','), index_category: llm.response_pos_category as usize, diff --git a/crates/common/src/enterprise/mod.rs b/crates/common/src/enterprise/mod.rs index dd9eb9cc..61acefb4 100644 --- a/crates/common/src/enterprise/mod.rs +++ b/crates/common/src/enterprise/mod.rs @@ -14,7 +14,7 @@ pub mod license; pub mod llm; use crate::{ - Core, Server, config::groupware::CalendarTemplateVariable, expr::Expression, + Core, LogoCache, Server, config::groupware::CalendarTemplateVariable, expr::Expression, manager::application::Resource, }; use ahash::{AHashMap, AHashSet}; @@ -136,7 +136,7 @@ impl Server { if let Some(enterprise) = &self.core.enterprise { let total_accounts = self.total_accounts().await.caused_by(trc::location!())?; - if total_accounts + 1 > enterprise.license.accounts as u64 { + if total_accounts + 1 > enterprise.license.accounts as usize { trc::event!( Server(trc::ServerEvent::Licensing), Details = "Account creation not possible: license key account limit reached", @@ -162,12 +162,11 @@ impl Server { let domain = psl::domain_str(domain).unwrap_or(domain); let logo = { self.inner.data.logos.lock().get(domain).cloned() }; if let Some(logo) = logo { - return Ok(logo); + return Ok(logo.data); } let Some((domain_id, tenant_id)) = self.domain(domain).await?.map(|d| (d.id, d.id_tenant)) else { - self.inner.data.logos.lock().insert(domain.into(), None); return Ok(None); }; @@ -222,11 +221,14 @@ impl Server { logo = Resource::new(content_type, contents).into(); } - self.inner - .data - .logos - .lock() - .insert(domain.into(), logo.clone()); + self.inner.data.logos.lock().insert( + domain.into(), + LogoCache { + domain_id, + tenant_id, + data: logo.clone(), + }, + ); Ok(logo) } diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index b6892d0b..1c6f448b 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -109,7 +109,7 @@ pub enum RegistryChange { Reload(ObjectType), } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum CacheInvalidation { AccessToken(u32), DavResources(u32), @@ -119,6 +119,8 @@ pub enum CacheInvalidation { Tenant(u32), Role(u32), List(u32), + DomainLogo(u32), + TenantLogo(u32), } #[derive(Debug)] diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b6717ec6..315b8e51 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -160,11 +160,18 @@ pub struct Data { pub queue_status: AtomicBool, pub applications: WebApplicationManager, - pub logos: Mutex, Option>>>>, + pub logos: Mutex, LogoCache>>, pub smtp_connectors: TlsConnectors, } +#[derive(Clone)] +pub struct LogoCache { + domain_id: u32, + tenant_id: Option, + data: Option>>, +} + pub struct Caches { pub access_tokens: Cache>, pub http_auth: Cache, HttpAuthCache>, diff --git a/crates/common/src/network/masked.rs b/crates/common/src/network/masked.rs index 3cf53bd8..bdad8a8d 100644 --- a/crates/common/src/network/masked.rs +++ b/crates/common/src/network/masked.rs @@ -9,7 +9,25 @@ use utils::snowflake::SnowflakeIdGenerator; pub struct MaskedAddress; +const U32_MAX: u128 = u32::MAX as u128; + impl MaskedAddress { + pub fn generate(address_id: u64, expires: Option, prefix: &str, domain: &str) -> String { + let expires = expires.unwrap_or(0) as u128 & U32_MAX; + let address_id = address_id as u128; + let ids = (address_id << 64) + | (expires << 32) + | ((address_id & U32_MAX) ^ (address_id >> 32) ^ expires); + + let mut address = String::with_capacity(prefix.len() + domain.len() + 30); + address.push_str(prefix); + address.push('.'); + address.push_str(&base36_encode(ids)); + address.push('@'); + address.push_str(domain); + address + } + pub fn parse(local_part: &str) -> Option { let mut parts = local_part.split('.'); let _prefix = parts.next().filter(|v| !v.is_empty())?; @@ -33,3 +51,16 @@ impl MaskedAddress { } } } + +fn base36_encode(mut n: u128) -> String { + const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + + let mut buf = Vec::with_capacity(25); + while n > 0 { + buf.push(CHARS[(n % 36) as usize]); + n /= 36; + } + + buf.reverse(); + String::from_utf8(buf).unwrap() +} diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index fe34e9ae..3a9cd6ce 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -12,7 +12,7 @@ use crate::{ use ahash::AHashSet; use registry::{ schema::{ - enums::BlockReason, + enums::{BlockReason, PasswordHashAlgorithm}, prelude::{Object, ObjectType}, structs::{self, AllowedIp, BlockedIp, Rate}, }, @@ -49,6 +49,8 @@ pub struct Security { pub default_role_ids_user: Vec, pub default_role_ids_group: Vec, pub default_role_ids_tenant: Vec, + + pub password_hash_algorithm: PasswordHashAlgorithm, } #[derive(Default)] @@ -144,6 +146,7 @@ impl Security { default_role_ids_user: auth.default_user_role_ids, default_role_ids_group: auth.default_group_role_ids, default_role_ids_tenant: auth.default_tenant_role_ids, + password_hash_algorithm: auth.password_hash_algorithm, } } } diff --git a/crates/common/src/sharing/acl.rs b/crates/common/src/sharing/acl.rs index acb2f644..0126e27e 100644 --- a/crates/common/src/sharing/acl.rs +++ b/crates/common/src/sharing/acl.rs @@ -4,12 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{Server, ipc::CacheInvalidation}; +use crate::{Server, cache::invalidate::CacheInvalidationBuilder, ipc::CacheInvalidation}; use types::acl::{AclGrant, ArchivedAclGrant}; impl Server { pub async fn refresh_acls(&self, acl_changes: &[AclGrant], current: Option<&[AclGrant]>) { - let mut changed_principals = Vec::new(); + let mut changed_principals = CacheInvalidationBuilder::default(); if let Some(acl_current) = current { for current_item in acl_current { let mut invalidate = true; @@ -21,7 +21,7 @@ impl Server { } if invalidate { changed_principals - .push(CacheInvalidation::AccessToken(current_item.account_id)); + .invalidate(CacheInvalidation::AccessToken(current_item.account_id)); } } @@ -34,16 +34,17 @@ impl Server { } } if invalidate { - changed_principals.push(CacheInvalidation::AccessToken(change_item.account_id)); + changed_principals + .invalidate(CacheInvalidation::AccessToken(change_item.account_id)); } } } else { for value in acl_changes { - changed_principals.push(CacheInvalidation::AccessToken(value.account_id)); + changed_principals.invalidate(CacheInvalidation::AccessToken(value.account_id)); } } - self.invalidate_caches(changed_principals, true).await; + self.invalidate_caches(changed_principals).await; } pub async fn refresh_archived_acls( @@ -51,7 +52,8 @@ impl Server { acl_changes: &[AclGrant], acl_current: &[ArchivedAclGrant], ) { - let mut changed_principals = Vec::new(); + let mut changed_principals = CacheInvalidationBuilder::default(); + for current_item in acl_current.iter() { let mut invalidate = true; for change_item in acl_changes { @@ -61,7 +63,7 @@ impl Server { } } if invalidate { - changed_principals.push(CacheInvalidation::AccessToken( + changed_principals.invalidate(CacheInvalidation::AccessToken( current_item.account_id.to_native(), )); } @@ -76,10 +78,11 @@ impl Server { } } if invalidate { - changed_principals.push(CacheInvalidation::AccessToken(change_item.account_id)); + changed_principals + .invalidate(CacheInvalidation::AccessToken(change_item.account_id)); } } - self.invalidate_caches(changed_principals, true).await; + self.invalidate_caches(changed_principals).await; } } diff --git a/crates/common/src/storage/mod.rs b/crates/common/src/storage/mod.rs index 5226bece..a0752c9b 100644 --- a/crates/common/src/storage/mod.rs +++ b/crates/common/src/storage/mod.rs @@ -89,18 +89,16 @@ impl Server { } } - pub async fn total_accounts(&self) -> trc::Result { + pub async fn total_accounts(&self) -> trc::Result { self.registry() - .query::(RegistryQuery::new(ObjectType::Account)) + .count(RegistryQuery::new(ObjectType::Account)) .await - .map(|r| r.len()) } - pub async fn total_domains(&self) -> trc::Result { + pub async fn total_domains(&self) -> trc::Result { self.registry() - .query::(RegistryQuery::new(ObjectType::Domain)) + .count(RegistryQuery::new(ObjectType::Domain)) .await - .map(|r| r.len()) } #[cfg(not(feature = "enterprise"))] diff --git a/crates/common/src/telemetry/tracers/store.rs b/crates/common/src/telemetry/tracers/store.rs index 9779e19e..68c777f8 100644 --- a/crates/common/src/telemetry/tracers/store.rs +++ b/crates/common/src/telemetry/tracers/store.rs @@ -120,7 +120,9 @@ fn map_value(value: &Value) -> TraceValue { }), Value::UInt(value) => TraceValue::UnsignedInt(TraceValueUnsignedInt { value: *value }), Value::Int(value) => TraceValue::Integer(TraceValueInteger { value: *value }), - Value::Float(value) => TraceValue::Float(TraceValueFloat { value: *value }), + Value::Float(value) => TraceValue::Float(TraceValueFloat { + value: (*value).into(), + }), Value::Timestamp(value) => TraceValue::UTCDateTime(TraceValueUTCDateTime { value: UTCDateTime::from_timestamp(*value as i64), }), diff --git a/crates/coordinator/src/backend/nats/mod.rs b/crates/coordinator/src/backend/nats/mod.rs index d1c5e2c5..deffcc4b 100644 --- a/crates/coordinator/src/backend/nats/mod.rs +++ b/crates/coordinator/src/backend/nats/mod.rs @@ -37,9 +37,13 @@ impl NatsPubSub { opts = opts.no_echo(); } - if let (Some(user), Some(pass)) = (config.auth_username, config.auth_secret) { + if let (Some(user), Some(pass)) = ( + config.auth_username, + config.auth_secret.secret().await?.map(|v| v.into_owned()), + ) { opts = opts.user_and_password(user.to_string(), pass.to_string()); - } else if let Some(credentials) = config.credentials { + } else if let Some(credentials) = config.credentials.secret().await?.map(|v| v.into_owned()) + { opts = opts .credentials(&credentials) .map_err(|err| format!("Failed to parse Nats credentials: {}", err))?; diff --git a/crates/directory/src/backend/ldap/config.rs b/crates/directory/src/backend/ldap/config.rs index b219c4da..8e8700b5 100644 --- a/crates/directory/src/backend/ldap/config.rs +++ b/crates/directory/src/backend/ldap/config.rs @@ -11,13 +11,18 @@ use ldap3::LdapConnSettings; use registry::schema::structs; impl LdapDirectory { - pub fn open(config: structs::LdapDirectory) -> Result { + pub async fn open(config: structs::LdapDirectory) -> Result { let bind_dn = if let Some(dn) = config.bind_dn { Bind::new( dn, - config.bind_secret.ok_or_else(|| { - "LDAP bind password is required when bind DN is set".to_string() - })?, + config + .bind_secret + .secret() + .await? + .map(|v| v.into_owned()) + .ok_or_else(|| { + "LDAP bind password is required when bind DN is set".to_string() + })?, ) .into() } else { diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs index ec472292..0c5ba690 100644 --- a/crates/directory/src/backend/oidc/config.rs +++ b/crates/directory/src/backend/oidc/config.rs @@ -9,7 +9,7 @@ use crate::Directory; use registry::schema::structs; impl OpenIdDirectory { - pub fn open(config: structs::OidcDirectory) -> Result { + pub async fn open(config: structs::OidcDirectory) -> Result { Ok(Directory::OpenId(match config { structs::OidcDirectory::UserInfo(config) => OpenIdDirectory::UserInfo { endpoint: config.endpoint, @@ -19,12 +19,15 @@ impl OpenIdDirectory { claim_name: config.claim_name, }, structs::OidcDirectory::Introspect(config) => { - let client = config.http_auth.build_http_client( - config.http_headers, - None, - config.timeout, - config.allow_invalid_certs, - )?; + let client = config + .http_auth + .build_http_client( + config.http_headers, + None, + config.timeout, + config.allow_invalid_certs, + ) + .await?; OpenIdDirectory::Introspect { client, endpoint: config.endpoint, diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs index 23db4ea8..3834912b 100644 --- a/crates/directory/src/core/config.rs +++ b/crates/directory/src/core/config.rs @@ -22,11 +22,11 @@ impl Directories { for directory in bp.list_infallible::().await { let id = directory.id; let result = match directory.object { - structs::Directory::Ldap(directory) => LdapDirectory::open(directory), + structs::Directory::Ldap(directory) => LdapDirectory::open(directory).await, structs::Directory::Sql(directory) => { SqlDirectory::open(directory, &bp.data_store).await } - structs::Directory::Oidc(directory) => OpenIdDirectory::open(directory), + structs::Directory::Oidc(directory) => OpenIdDirectory::open(directory).await, }; match result { diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index 4d7b0443..e7326b9f 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -5,11 +5,15 @@ */ use argon2::Argon2; +use argon2::PasswordHasher; use mail_builder::encoders::base64::base64_encode; use mail_parser::decoders::base64::base64_decode; use password_hash::PasswordHash; +use password_hash::SaltString; +use password_hash::rand_core::OsRng; use pbkdf2::Pbkdf2; use pwhash::{bcrypt, bsdi_crypt, md5_crypt, sha1_crypt, sha256_crypt, sha512_crypt, unix_crypt}; +use registry::schema::enums::PasswordHashAlgorithm; use scrypt::Scrypt; use sha1::Digest; use sha1::Sha1; @@ -219,3 +223,50 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu Ok(false) } } + +pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: String) -> trc::Result { + let (tx, rx) = oneshot::channel(); + + tokio::task::spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + + let result = match algorithm { + PasswordHashAlgorithm::Argon2id => { + let hasher = Argon2::default(); + hasher + .hash_password(secret.as_bytes(), &salt) + .map(|h| h.to_string()) + } + PasswordHashAlgorithm::Bcrypt => { + return tx + .send(bcrypt::hash(secret.as_bytes()).map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("Bcrypt hash failed") + })) + .ok() + .unwrap_or(()); + } + PasswordHashAlgorithm::Scrypt => Scrypt + .hash_password(secret.as_bytes(), &salt) + .map(|h| h.to_string()), + PasswordHashAlgorithm::Pbkdf2 => Pbkdf2 + .hash_password(secret.as_bytes(), &salt) + .map(|h| h.to_string()), + }; + + tx.send(result.map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("Password hash failed") + })) + .ok(); + }); + + match rx.await { + Ok(result) => result, + Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) + .caused_by(trc::location!()) + .reason(err)), + } +} diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index a8af4123..7400e4dc 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -374,7 +374,7 @@ impl Session { // Invalidate ACLs data.server - .invalidate_caches(vec![CacheInvalidation::AccessToken(acl_account_id)], true) + .invalidate_caches(CacheInvalidation::AccessToken(acl_account_id).into()) .await; trc::event!( diff --git a/crates/jmap-proto/src/error/set.rs b/crates/jmap-proto/src/error/set.rs index bffaac33..7976a1f4 100644 --- a/crates/jmap-proto/src/error/set.rs +++ b/crates/jmap-proto/src/error/set.rs @@ -5,24 +5,46 @@ */ use jmap_tools::{Key, Property}; +use registry::types::{ + error::{PatchError, ValidationError}, + id::ObjectId, +}; use std::borrow::Cow; use types::id::Id; #[derive(Debug, Clone, serde::Serialize)] #[serde(bound(serialize = "InvalidProperty

: serde::Serialize"))] -pub struct SetError { +#[serde(transparent)] +#[repr(transparent)] +pub struct SetError(Box>); + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(bound(serialize = "InvalidProperty

: serde::Serialize"))] +struct SetErrorInner { #[serde(rename = "type")] - pub type_: SetErrorType, + type_: SetErrorType, #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option>, + description: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>>, + properties: Option>>, #[serde(rename = "existingId")] #[serde(skip_serializing_if = "Option::is_none")] existing_id: Option, + + #[serde(rename = "objectId")] + #[serde(skip_serializing_if = "Option::is_none")] + object_id: Option, + + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(rename = "linkedObjects")] + linked_objects: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(rename = "validationErrors")] + validation_errors: Vec, } #[derive(Debug, Clone)] @@ -89,6 +111,15 @@ pub enum SetErrorType { NodeHasChildren, #[serde(rename = "calendarHasEvent")] CalendarHasEvent, + // Stalwart registry errors + #[serde(rename = "objectIsLinked")] + ObjectIsLinked, + #[serde(rename = "invalidForeignKey")] + InvalidForeignKey, + #[serde(rename = "primaryKeyViolation")] + PrimaryKeyViolation, + #[serde(rename = "validationFailed")] + ValidationFailed, } impl SetErrorType { @@ -122,27 +153,34 @@ impl SetErrorType { SetErrorType::AddressBookHasContents => "addressBookHasContents", SetErrorType::NodeHasChildren => "nodeHasChildren", SetErrorType::CalendarHasEvent => "calendarHasEvent", + SetErrorType::ObjectIsLinked => "objectIsLinked", + SetErrorType::InvalidForeignKey => "invalidForeignKey", + SetErrorType::PrimaryKeyViolation => "primaryKeyViolation", + SetErrorType::ValidationFailed => "validationFailed", } } } impl SetError { pub fn new(type_: SetErrorType) -> Self { - SetError { + SetError(Box::new(SetErrorInner { type_, description: None, properties: None, existing_id: None, - } + object_id: None, + linked_objects: Vec::new(), + validation_errors: Vec::new(), + })) } pub fn with_description(mut self, description: impl Into>) -> Self { - self.description = description.into().into(); + self.0.description = description.into().into(); self } pub fn with_property(mut self, property: impl Into>) -> Self { - self.properties = vec![property.into()].into(); + self.0.properties = vec![property.into()].into(); self } @@ -150,7 +188,7 @@ impl SetError { mut self, properties: impl IntoIterator>>, ) -> Self { - self.properties = properties + self.0.properties = properties .into_iter() .map(Into::into) .collect::>() @@ -158,8 +196,23 @@ impl SetError { self } + pub fn with_object_id(mut self, object_id: ObjectId) -> Self { + self.0.object_id = object_id.into(); + self + } + + pub fn with_linked_objects(mut self, linked_objects: Vec) -> Self { + self.0.linked_objects = linked_objects; + self + } + + pub fn with_validation_errors(mut self, validation_errors: Vec) -> Self { + self.0.validation_errors = validation_errors; + self + } + pub fn with_existing_id(mut self, id: Id) -> Self { - self.existing_id = id.into(); + self.0.existing_id = id.into(); self } @@ -253,3 +306,17 @@ impl serde::Serialize for InvalidProperty { } } } + +impl From for SetError { + fn from(err: PatchError) -> Self { + SetError(Box::new(SetErrorInner { + type_: SetErrorType::InvalidPatch, + description: err.message.into(), + properties: Some(vec![InvalidProperty::Property(Key::Owned(err.path))]), + existing_id: None, + object_id: None, + linked_objects: Vec::new(), + validation_errors: Vec::new(), + })) + } +} diff --git a/crates/jmap/src/registry/get.rs b/crates/jmap/src/registry/get.rs index 3e75ed8d..0ce6f0b2 100644 --- a/crates/jmap/src/registry/get.rs +++ b/crates/jmap/src/registry/get.rs @@ -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) diff --git a/crates/jmap/src/registry/mapping/masked_email.rs b/crates/jmap/src/registry/mapping/masked_email.rs new file mode 100644 index 00000000..12d122b0 --- /dev/null +++ b/crates/jmap/src/registry/mapping/masked_email.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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>, +) -> 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::() + }; + + 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)) +} diff --git a/crates/jmap/src/registry/mapping/mod.rs b/crates/jmap/src/registry/mapping/mod.rs index cd475a25..9abf9264 100644 --- a/crates/jmap/src/registry/mapping/mod.rs +++ b/crates/jmap/src/registry/mapping/mod.rs @@ -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>>; + +pub struct ObjectResponse { + pub id: Option, + 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), + } + } +} diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs new file mode 100644 index 00000000..72a4d0b9 --- /dev/null +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -0,0 +1,248 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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 + // 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::(); + } + + 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) -> SetError { + 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 + )) +} diff --git a/crates/jmap/src/registry/mapping/public_key.rs b/crates/jmap/src/registry/mapping/public_key.rs new file mode 100644 index 00000000..8d5aac24 --- /dev/null +++ b/crates/jmap/src/registry/mapping/public_key.rs @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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>, +) -> 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!() +} diff --git a/crates/jmap/src/registry/mapping/report.rs b/crates/jmap/src/registry/mapping/report.rs index 94cecbb0..8de85fec 100644 --- a/crates/jmap/src/registry/mapping/report.rs +++ b/crates/jmap/src/registry/mapping/report.rs @@ -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); } diff --git a/crates/jmap/src/registry/mapping/spam_sample.rs b/crates/jmap/src/registry/mapping/spam_sample.rs index 6866ef96..7bc8f36f 100644 --- a/crates/jmap/src/registry/mapping/spam_sample.rs +++ b/crates/jmap/src/registry/mapping/spam_sample.rs @@ -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, diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 9a073cb0..2e517f00 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -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>> + 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> { 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 + // 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 - // 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); -} - -impl SetModification for SetResponse { +impl RegistrySetResponse<'_> { fn failed(&mut self, modification: Modification, error: SetError) { 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 { + 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 { + 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!(), + } +} diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 0ed1ed50..3e106f6f 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -40,6 +40,7 @@ tokio = { version = "1.47", features = ["full"] } jemallocator = "0.5.0" [features] +#default = ["sqlite", "postgres", "mysql", "rocks", "s3", "redis", "azure", "nats", "enterprise", "zenoh", "kafka"] #default = ["sqlite", "postgres", "mysql", "rocks", "s3", "redis", "azure", "nats", "enterprise"] default = ["rocks", "enterprise"] sqlite = ["store/sqlite", "directory/sqlite"] diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 2f7e4bce..e9a47165 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -14,6 +14,7 @@ ahash = { version = "0.8" } jmap-tools = { version = "0.1" } xxhash-rust = { version = "0.8.5", features = ["xxh3"] } mail-auth = { path = "/Users/me/code/mail-auth" } +tokio = { version = "1.47", features = ["fs"] } [features] test_mode = [] diff --git a/crates/registry/src/jmap/mod.rs b/crates/registry/src/jmap/mod.rs index 630c7123..8a451b17 100644 --- a/crates/registry/src/jmap/mod.rs +++ b/crates/registry/src/jmap/mod.rs @@ -11,6 +11,7 @@ use crate::{ use jmap_tools::{JsonPointer, Value}; use std::fmt::Debug; use types::{blob::BlobId, id::Id}; +use utils::map::vec_map::VecMap; pub mod patch; pub mod properties; @@ -25,6 +26,19 @@ pub enum RegistryValue { IdReference(String), } +pub type PatchResult<'x> = Result, PatchError>; + +pub enum MaybeUnpatched<'x> { + Unpatched { + property: Property, + value: JmapValue<'x>, + }, + UnpatchedMany { + properties: VecMap>, + }, + Patched, +} + #[derive(Clone)] pub struct JsonPointerPatch<'x> { ptr: &'x JsonPointer, @@ -34,26 +48,20 @@ pub struct JsonPointerPatch<'x> { } pub trait RegistryJsonPatch: Debug + Default { - fn patch( - &mut self, - pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError>; + fn patch<'x>(&mut self, pointer: JsonPointerPatch<'_>, value: JmapValue<'x>) + -> PatchResult<'x>; } pub trait RegistryJsonPropertyPatch: Debug + Default { - fn patch_property( + fn patch_property<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError>; + value: JmapValue<'x>, + ) -> PatchResult<'x>; } pub trait RegistryJsonEnumPatch: Debug { - fn patch( - &mut self, - pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError>; + fn patch<'x>(&mut self, pointer: JsonPointerPatch<'_>, value: JmapValue<'x>) + -> PatchResult<'x>; } pub trait IntoValue { diff --git a/crates/registry/src/jmap/patch.rs b/crates/registry/src/jmap/patch.rs index eb4af1e0..406ad3ae 100644 --- a/crates/registry/src/jmap/patch.rs +++ b/crates/registry/src/jmap/patch.rs @@ -6,8 +6,8 @@ use crate::{ jmap::{ - JsonPointerPatch, RegistryJsonEnumPatch, RegistryJsonPatch, RegistryJsonPropertyPatch, - RegistryValue, + JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonEnumPatch, + RegistryJsonPatch, RegistryJsonPropertyPatch, RegistryValue, }, schema::prelude::Property, types::{ @@ -76,11 +76,11 @@ impl<'x> JsonPointerPatch<'x> { self.ptr.as_slice().len() > self.pos } - pub fn assert_eof(&self) -> Result<(), PatchError> { + pub fn assert_eof(&self) -> PatchResult<'static> { if self.has_next() { Err(PatchError::new(self.cloned(), "Invalid JSON Pointer path")) } else { - Ok(()) + Ok(MaybeUnpatched::Patched) } } @@ -97,11 +97,11 @@ impl<'x> JsonPointerPatch<'x> { } impl RegistryJsonPatch for Option { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Value::Null = value { *self = None; pointer.assert_eof() @@ -115,11 +115,11 @@ impl RegistryJsonPatch for Option { } impl RegistryJsonEnumPatch for Option { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Value::Null = value { *self = None; pointer.assert_eof() @@ -133,11 +133,11 @@ impl RegistryJsonEnumPatch for Option { } impl RegistryJsonPatch for String { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Some(value) = value.into_string().filter(|v| !v.is_empty()) { let mut value = value.into_owned(); @@ -160,11 +160,11 @@ impl RegistryJsonPatch for String { } impl RegistryJsonPatch for bool { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Some(new_value) = value.as_bool() { *self = new_value; pointer.assert_eof() @@ -178,11 +178,11 @@ impl RegistryJsonPatch for bool { } impl RegistryJsonPatch for u64 { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Some(new_value) = value.as_u64() { *self = new_value; pointer.assert_eof() @@ -196,11 +196,11 @@ impl RegistryJsonPatch for u64 { } impl RegistryJsonPatch for i64 { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Some(new_value) = value.as_i64() { *self = new_value; pointer.assert_eof() @@ -213,30 +213,12 @@ impl RegistryJsonPatch for i64 { } } -impl RegistryJsonPatch for f64 { - fn patch( - &mut self, - pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { - if let Some(new_value) = value.as_f64().filter(|v| v.is_finite()) { - *self = new_value; - pointer.assert_eof() - } else { - Err(PatchError::new( - pointer, - "Invalid value for float property (expected finite number)", - )) - } - } -} - impl RegistryJsonPatch for trc::Key { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: super::JmapValue<'_>, - ) -> Result<(), PatchError> { + value: super::JmapValue<'x>, + ) -> PatchResult<'x> { if let Some(new_value) = value.as_str().and_then(|v| trc::Key::try_parse(v.as_ref())) { *self = new_value; pointer.assert_eof() @@ -250,11 +232,11 @@ impl RegistryJsonPatch for trc::Key { } impl RegistryJsonEnumPatch for T { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if let Some(new_value) = value.as_str().and_then(|v| T::parse(v.as_ref())) { *self = new_value; pointer.assert_eof() @@ -268,16 +250,16 @@ impl RegistryJsonEnumPatch for T { } impl RegistryJsonPatch for Vec { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (pointer.next(), value) { (Some(JsonPointerItem::Number(idx)), Value::Null) => { if *idx < self.len() as u64 { self.remove(*idx as usize); - return Ok(()); + return Ok(MaybeUnpatched::Patched); } } (Some(JsonPointerItem::Number(idx)), value) => { @@ -295,7 +277,7 @@ impl RegistryJsonPatch for Vec { inner.patch(pointer.clone(), item)?; self.push(inner); } - return Ok(()); + return Ok(MaybeUnpatched::Patched); } _ => {} } @@ -305,16 +287,16 @@ impl RegistryJsonPatch for Vec { } impl RegistryJsonEnumPatch for Vec { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (pointer.next(), value) { (Some(JsonPointerItem::Number(idx)), Value::Null) => { if *idx < self.len() as u64 { self.remove(*idx as usize); - return Ok(()); + return Ok(MaybeUnpatched::Patched); } } (Some(JsonPointerItem::Number(idx)), value) => { @@ -332,7 +314,7 @@ impl RegistryJsonEnumPatch for Vec { inner.patch(pointer.clone(), item)?; self.push(inner); } - return Ok(()); + return Ok(MaybeUnpatched::Patched); } _ => {} } @@ -342,22 +324,22 @@ impl RegistryJsonEnumPatch for Vec { } impl RegistryJsonPatch for VecMap { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: Value<'_, Property, RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (pointer.next(), value) { (Some(JsonPointerItem::Number(idx)), Value::Null) => { if let Some(key) = K::try_from_integer(*idx) { self.remove(&key); - return Ok(()); + return Ok(MaybeUnpatched::Patched); } } (Some(JsonPointerItem::Key(key)), Value::Null) => { if let Some(key) = K::try_from_string(key.to_string().as_ref()) { self.remove(&key); - return Ok(()); + return Ok(MaybeUnpatched::Patched); } } (Some(JsonPointerItem::Key(key)), value) => { @@ -384,7 +366,7 @@ impl RegistryJsonPatch for VecMap { )); } } - return Ok(()); + return Ok(MaybeUnpatched::Patched); } _ => {} } @@ -397,20 +379,27 @@ impl RegistryJsonPatch for VecMap { } impl RegistryJsonPatch for T { - fn patch( + fn patch<'x>( &mut self, pointer: JsonPointerPatch<'_>, - value: jmap_tools::Value<'_, Property, crate::jmap::RegistryValue>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { if pointer.has_next() { self.patch_property(pointer, value) } else if let Some(object) = value.into_object() { let mut ptr = JsonPointer::new(vec![JsonPointerItem::Root]); + let mut unpatched = VecMap::new(); for (key, value) in object.into_vec() { if let Some(property) = key.as_property() { ptr.as_mut_slice()[0] = JsonPointerItem::Key(Key::Property(*property)); match self.patch_property(JsonPointerPatch::new(&ptr), value) { - Ok(()) => {} + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { property, value }) => { + unpatched.append(property, value); + } + Ok(MaybeUnpatched::UnpatchedMany { properties }) => { + unpatched.extend(properties.into_iter()); + } Err(mut e) => { if !e.path.is_empty() { e.path = format!("{}/{}", e.path, property.as_str()); @@ -424,7 +413,13 @@ impl RegistryJsonPatch for T { return Err(PatchError::new(pointer.clone(), "Invalid key for object")); } } - Ok(()) + if unpatched.is_empty() { + Ok(MaybeUnpatched::Patched) + } else { + Ok(MaybeUnpatched::UnpatchedMany { + properties: unpatched, + }) + } } else { Err(PatchError::new(pointer, "Invalid value type for object")) } diff --git a/crates/registry/src/jmap/ser.rs b/crates/registry/src/jmap/ser.rs index 8fc69e81..df048c17 100644 --- a/crates/registry/src/jmap/ser.rs +++ b/crates/registry/src/jmap/ser.rs @@ -46,12 +46,6 @@ impl IntoValue for i64 { } } -impl IntoValue for f64 { - fn into_value(self) -> JmapValue<'static> { - JmapValue::Number(self.into()) - } -} - impl IntoValue for T { fn into_value(self) -> JmapValue<'static> { JmapValue::Str(self.as_str().into()) diff --git a/crates/registry/src/pickle.rs b/crates/registry/src/pickle.rs index 43a1751b..43351499 100644 --- a/crates/registry/src/pickle.rs +++ b/crates/registry/src/pickle.rs @@ -105,18 +105,6 @@ impl Pickle for i64 { } } -impl Pickle for f64 { - fn pickle(&self, out: &mut Vec) { - out.extend_from_slice(&self.to_be_bytes()); - } - - fn unpickle(stream: &mut PickledStream<'_>) -> Option { - let mut arr = [0u8; std::mem::size_of::()]; - arr.copy_from_slice(stream.read_bytes(std::mem::size_of::())?); - Some(f64::from_be_bytes(arr)) - } -} - impl Pickle for bool { fn pickle(&self, out: &mut Vec) { out.push(if *self { 1 } else { 0 }); diff --git a/crates/registry/src/schema/prelude.rs b/crates/registry/src/schema/prelude.rs index ab180ff6..0f23c76f 100644 --- a/crates/registry/src/schema/prelude.rs +++ b/crates/registry/src/schema/prelude.rs @@ -5,6 +5,8 @@ */ pub use crate::jmap::IntoValue; pub use crate::jmap::JmapValue; +pub use crate::jmap::MaybeUnpatched; +pub use crate::jmap::PatchResult; pub use crate::jmap::{ JsonPointerPatch, RegistryJsonEnumPatch, RegistryJsonPatch, RegistryJsonPropertyPatch, patch::object_type, @@ -18,6 +20,7 @@ pub use crate::types::ObjectImpl; pub use crate::types::datetime::UTCDateTime; pub use crate::types::duration::Duration; pub use crate::types::error::*; +pub use crate::types::float::Float; pub use crate::types::index::IndexBuilder; pub use crate::types::ipaddr::IpAddr; pub use crate::types::ipmask::IpAddrOrMask; @@ -48,3 +51,5 @@ pub const OBJ_SINGLETON: u64 = 1; pub const OBJ_SEQ_ID: u64 = 1 << 1; pub const OBJ_FILTER_ACCOUNT: u64 = 1 << 2; pub const OBJ_FILTER_TENANT: u64 = 1 << 3; + +pub const MASKED_PASSWORD: &str = "****"; diff --git a/crates/registry/src/types/datetime.rs b/crates/registry/src/types/datetime.rs index ca9d78e8..81e8c42f 100644 --- a/crates/registry/src/types/datetime.rs +++ b/crates/registry/src/types/datetime.rs @@ -5,7 +5,9 @@ */ use crate::{ - jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch}, + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, pickle::{Pickle, PickledStream}, types::error::PatchError, }; @@ -267,16 +269,16 @@ impl From for UTCDateTime { } impl RegistryJsonPatch for UTCDateTime { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Str(value), None) => { if let Ok(new_value) = UTCDateTime::from_str(value.as_ref()) { *self = new_value; - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new( pointer, diff --git a/crates/registry/src/types/duration.rs b/crates/registry/src/types/duration.rs index 2f9ae3b1..206f3978 100644 --- a/crates/registry/src/types/duration.rs +++ b/crates/registry/src/types/duration.rs @@ -5,7 +5,9 @@ */ use crate::{ - jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch}, + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, pickle::{Pickle, PickledStream}, types::error::PatchError, }; @@ -134,16 +136,16 @@ impl Pickle for Duration { } impl RegistryJsonPatch for Duration { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Number(value), None) => { if let Some(new_value) = value.as_u64().filter(|v| *v > 0) { *self = Duration::from_millis(new_value); - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new(pointer, "Invalid duration value")) } diff --git a/crates/registry/src/types/error.rs b/crates/registry/src/types/error.rs index f98969eb..92ba3013 100644 --- a/crates/registry/src/types/error.rs +++ b/crates/registry/src/types/error.rs @@ -7,7 +7,8 @@ use crate::{jmap::JsonPointerPatch, schema::prelude::Property, types::id::ObjectId}; use std::{borrow::Cow, fmt::Display}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "type")] pub enum ValidationError { Invalid { property: Property, value: String }, Required { property: Property }, diff --git a/crates/registry/src/types/float.rs b/crates/registry/src/types/float.rs new file mode 100644 index 00000000..c935c856 --- /dev/null +++ b/crates/registry/src/types/float.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{IntoValue, JmapValue, JsonPointerPatch, PatchResult, RegistryJsonPatch}, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone, Copy, PartialEq)] +#[repr(transparent)] +pub struct Float(f64); + +impl Eq for Float {} + +impl PartialOrd for Float { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Float { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.partial_cmp(other).unwrap_or_else(|| { + if self.0.is_nan() && other.0.is_nan() { + std::cmp::Ordering::Equal + } else if self.0.is_nan() { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Less + } + }) + } +} + +impl Float { + pub fn new(value: f64) -> Self { + Float(value) + } + + pub fn into_inner(self) -> f64 { + self.0 + } + + pub fn is_valid(&self) -> bool { + !self.0.is_nan() && self.0.is_finite() + } +} + +impl FromStr for Float { + type Err = String; + + fn from_str(s: &str) -> Result { + s.parse::().map(Float).map_err(|err| err.to_string()) + } +} + +impl Display for Float { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl serde::Serialize for Float { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_f64(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Float { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + f64::deserialize(deserializer) + .map(Float::new) + .map_err(|_| serde::de::Error::custom("invalid Float")) + } +} + +impl AsRef for Float { + fn as_ref(&self) -> &f64 { + &self.0 + } +} + +impl Default for Float { + fn default() -> Self { + Float(f64::NAN) + } +} + +impl From for Float { + fn from(value: f64) -> Self { + Float(value) + } +} + +impl Pickle for Float { + fn pickle(&self, out: &mut Vec) { + self.0.to_bits().pickle(out); + } + + fn unpickle(data: &mut PickledStream<'_>) -> Option { + u64::unpickle(data).map(|bits| Float(f64::from_bits(bits))) + } +} + +impl RegistryJsonPatch for Float { + fn patch<'x>( + &mut self, + pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + if let Some(new_value) = value.as_f64().filter(|v| v.is_finite() && !v.is_nan()) { + *self = Float(new_value); + pointer.assert_eof() + } else { + Err(PatchError::new( + pointer, + "Invalid value for float property (expected finite number)", + )) + } + } +} + +impl IntoValue for Float { + fn into_value(self) -> JmapValue<'static> { + JmapValue::Number(self.0.into()) + } +} diff --git a/crates/registry/src/types/id.rs b/crates/registry/src/types/id.rs index c94b740b..34fa4a2c 100644 --- a/crates/registry/src/types/id.rs +++ b/crates/registry/src/types/id.rs @@ -5,7 +5,10 @@ */ use crate::{ - jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch, RegistryValue}, + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + RegistryValue, + }, pickle::{Pickle, PickledStream}, schema::prelude::ObjectType, types::{EnumImpl, error::PatchError}, @@ -17,7 +20,7 @@ use types::{ id::Id, }; -#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)] +#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash, serde::Serialize)] pub struct ObjectId { object: ObjectType, id: Id, @@ -95,20 +98,20 @@ impl Pickle for BlobId { } impl RegistryJsonPatch for Id { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Element(RegistryValue::Id(value)), None) => { *self = value; - Ok(()) + Ok(MaybeUnpatched::Patched) } (jmap_tools::Value::Str(value), None) => { if let Ok(new_value) = Id::from_str(value.as_ref()) { *self = new_value; - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new(pointer, "Failed to parse Id from string")) } @@ -122,20 +125,20 @@ impl RegistryJsonPatch for Id { } impl RegistryJsonPatch for BlobId { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Element(RegistryValue::BlobId(value)), None) => { *self = value; - Ok(()) + Ok(MaybeUnpatched::Patched) } (jmap_tools::Value::Str(value), None) => { if let Ok(new_value) = BlobId::from_str(value.as_ref()) { *self = new_value; - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new( pointer, diff --git a/crates/registry/src/types/ipaddr.rs b/crates/registry/src/types/ipaddr.rs index 4b09afe3..efbf1cfc 100644 --- a/crates/registry/src/types/ipaddr.rs +++ b/crates/registry/src/types/ipaddr.rs @@ -4,15 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{fmt::Display, net::Ipv4Addr, str::FromStr}; - use crate::{ - jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch}, + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, pickle::{Pickle, PickledStream}, types::error::PatchError, }; +use std::{fmt::Display, net::Ipv4Addr, str::FromStr}; -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(transparent)] pub struct IpAddr(pub std::net::IpAddr); @@ -119,16 +120,16 @@ impl Pickle for IpAddr { } impl RegistryJsonPatch for IpAddr { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Str(value), None) => { if let Ok(new_value) = IpAddr::from_str(value.as_ref()) { *self = new_value; - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new( pointer, diff --git a/crates/registry/src/types/ipmask.rs b/crates/registry/src/types/ipmask.rs index 43d1cea6..d932f666 100644 --- a/crates/registry/src/types/ipmask.rs +++ b/crates/registry/src/types/ipmask.rs @@ -5,7 +5,9 @@ */ use crate::{ - jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch}, + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, pickle::{Pickle, PickledStream}, types::error::PatchError, }; @@ -248,16 +250,16 @@ impl Pickle for IpAddrOrMask { } impl RegistryJsonPatch for IpAddrOrMask { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Str(value), None) => { if let Ok(new_value) = IpAddrOrMask::from_str(value.as_ref()) { *self = new_value; - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new( pointer, diff --git a/crates/registry/src/types/mod.rs b/crates/registry/src/types/mod.rs index 6e61a801..1048f743 100644 --- a/crates/registry/src/types/mod.rs +++ b/crates/registry/src/types/mod.rs @@ -15,6 +15,7 @@ use std::fmt::Debug; pub mod datetime; pub mod duration; pub mod error; +pub mod float; pub mod id; pub mod index; pub mod ipaddr; diff --git a/crates/registry/src/types/socketaddr.rs b/crates/registry/src/types/socketaddr.rs index 14c9d7b2..873fe8f7 100644 --- a/crates/registry/src/types/socketaddr.rs +++ b/crates/registry/src/types/socketaddr.rs @@ -4,15 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{fmt::Display, str::FromStr}; - use crate::{ - jmap::{IntoValue, JmapValue, JsonPointerPatch, RegistryJsonPatch}, + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, pickle::{Pickle, PickledStream}, types::error::PatchError, }; +use std::{fmt::Display, str::FromStr}; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SocketAddr(pub std::net::SocketAddr); impl SocketAddr { @@ -86,16 +87,16 @@ impl Pickle for SocketAddr { } impl RegistryJsonPatch for SocketAddr { - fn patch( + fn patch<'x>( &mut self, mut pointer: JsonPointerPatch<'_>, - value: JmapValue<'_>, - ) -> Result<(), PatchError> { + value: JmapValue<'x>, + ) -> PatchResult<'x> { match (value, pointer.next()) { (jmap_tools::Value::Str(value), None) => { if let Ok(new_value) = SocketAddr::from_str(value.as_ref()) { *self = new_value; - Ok(()) + Ok(MaybeUnpatched::Patched) } else { Err(PatchError::new( pointer, diff --git a/crates/registry/src/utils/http.rs b/crates/registry/src/utils/http.rs index 0febc1bf..07ae601c 100644 --- a/crates/registry/src/utils/http.rs +++ b/crates/registry/src/utils/http.rs @@ -12,7 +12,7 @@ use utils::{ }; impl HttpAuth { - pub fn build_headers( + pub async fn build_headers( &self, extra_headers: VecMap, content_type: Option<&str>, @@ -24,7 +24,7 @@ impl HttpAuth { HttpAuth::Basic(auth) => build_http_headers( extra_headers, auth.username.as_str().into(), - auth.secret.as_str().into(), + auth.secret.secret().await?.as_ref().into(), None, content_type, ), @@ -32,13 +32,13 @@ impl HttpAuth { extra_headers, None, None, - auth.bearer_token.as_str().into(), + auth.bearer_token.secret().await?.as_ref().into(), content_type, ), } } - pub fn build_http_client( + pub async fn build_http_client( &self, extra_headers: VecMap, content_type: Option<&str>, @@ -58,7 +58,7 @@ impl HttpAuth { HttpAuth::Basic(auth) => build_http_client( extra_headers, auth.username.as_str().into(), - auth.secret.as_str().into(), + auth.secret.secret().await?.as_ref().into(), None, content_type, timeout.into_inner(), @@ -68,7 +68,7 @@ impl HttpAuth { extra_headers, None, None, - auth.bearer_token.as_str().into(), + auth.bearer_token.secret().await?.as_ref().into(), content_type, timeout.into_inner(), allow_invalid_certs, diff --git a/crates/registry/src/utils/mod.rs b/crates/registry/src/utils/mod.rs index bb04baf1..9d9e90c6 100644 --- a/crates/registry/src/utils/mod.rs +++ b/crates/registry/src/utils/mod.rs @@ -4,8 +4,21 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::schema::prelude::Roles; +use types::id::Id; + pub mod account; pub mod cron; pub mod http; pub mod report; +pub mod secret; pub mod task; + +impl Roles { + pub fn role_ids(&self) -> Option<&[Id]> { + match self { + Roles::Default => None, + Roles::Custom(custom_roles) => Some(&custom_roles.role_ids), + } + } +} diff --git a/crates/registry/src/utils/report.rs b/crates/registry/src/utils/report.rs index 94095d91..fc930b0b 100644 --- a/crates/registry/src/utils/report.rs +++ b/crates/registry/src/utils/report.rs @@ -19,7 +19,7 @@ use types::id::Id; pub trait ReportIndex { fn text(&self) -> impl Iterator; - fn tenant_ids(&self) -> &[Id]; + fn tenant_id(&self) -> Option; fn expires_at(&self) -> u64; @@ -30,7 +30,7 @@ pub trait ReportIndex { index.text(Property::Domain, text); } - for tenant_id in self.tenant_ids() { + if let Some(tenant_id) = self.tenant_id() { index.search(Property::MemberTenantId, tenant_id.id()); } @@ -47,16 +47,11 @@ impl ReportIndex for structs::ArfExternalReport { .iter() .filter_map(|s| non_empty(s)) .chain( - [ - report.dkim_domain.as_deref(), - report.original_mail_from.as_deref(), - ] - .into_iter() - .flatten() - .filter_map(non_empty), + [report.dkim_domain.as_deref()] + .into_iter() + .flatten() + .filter_map(non_empty), ) - .chain(self.to.iter().filter_map(|s| non_empty(s))) - .map(|domain| domain.rsplit_once('@').map(|(_, d)| d).unwrap_or(domain)) } fn text(&self) -> impl Iterator { @@ -80,8 +75,8 @@ impl ReportIndex for structs::ArfExternalReport { .chain(non_empty(&self.from)) } - fn tenant_ids(&self) -> &[Id] { - &self.member_tenant_id + fn tenant_id(&self) -> Option { + self.member_tenant_id } fn expires_at(&self) -> u64 { @@ -96,16 +91,6 @@ impl ReportIndex for structs::DmarcExternalReport { non_empty(&report.policy_domain) .into_iter() .filter_map(non_empty) - .chain(report.records.iter().flat_map(|r| { - non_empty(&r.envelope_from) - .into_iter() - .filter_map(non_empty) - .chain(non_empty(&r.header_from)) - .chain(r.dkim_results.iter().filter_map(|d| non_empty(&d.domain))) - .chain(r.spf_results.iter().filter_map(|s| non_empty(&s.domain))) - })) - .chain(self.to.iter().filter_map(|s| non_empty(s))) - .map(|domain| domain.rsplit_once('@').map(|(_, d)| d).unwrap_or(domain)) } fn text(&self) -> impl Iterator { @@ -128,8 +113,8 @@ impl ReportIndex for structs::DmarcExternalReport { .chain(non_empty(&self.from)) } - fn tenant_ids(&self) -> &[Id] { - &self.member_tenant_id + fn tenant_id(&self) -> Option { + self.member_tenant_id } fn expires_at(&self) -> u64 { @@ -144,13 +129,7 @@ impl ReportIndex for structs::TlsExternalReport { report .policies .iter() - .flat_map(|p| { - non_empty(&p.policy_domain) - .into_iter() - .chain(p.mx_hosts.iter().filter_map(|s| non_empty(s))) - }) - .chain(self.to.iter().filter_map(|s| non_empty(s))) - .map(|domain| domain.rsplit_once('@').map(|(_, d)| d).unwrap_or(domain)) + .flat_map(|p| non_empty(&p.policy_domain).into_iter()) } fn text(&self) -> impl Iterator { @@ -172,8 +151,8 @@ impl ReportIndex for structs::TlsExternalReport { .chain(non_empty(&self.from)) } - fn tenant_ids(&self) -> &[Id] { - &self.member_tenant_id + fn tenant_id(&self) -> Option { + self.member_tenant_id } fn expires_at(&self) -> u64 { @@ -513,7 +492,7 @@ impl From for structs::DmarcReportRecord { impl From for Report { fn from(value: structs::DmarcReport) -> Self { Report { - version: value.version as f32, + version: value.version.into_inner() as f32, report_metadata: ReportMetadata { org_name: value.org_name, email: value.email, @@ -544,7 +523,7 @@ impl From for Report { impl From for structs::DmarcReport { fn from(value: Report) -> Self { structs::DmarcReport { - version: value.version as f64, + version: (value.version as f64).into(), date_range_begin: UTCDateTime::from_timestamp( value.report_metadata.date_range.begin as i64, ), diff --git a/crates/registry/src/utils/secret.rs b/crates/registry/src/utils/secret.rs new file mode 100644 index 00000000..ce200bc8 --- /dev/null +++ b/crates/registry/src/utils/secret.rs @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::schema::prelude::{ + SecretKey, SecretKeyEnvironmentVariable, SecretKeyFile, SecretKeyOptional, SecretKeyValue, + SecretText, SecretTextOptional, SecretTextValue, +}; +use std::borrow::Cow; + +impl SecretKey { + pub async fn secret(&self) -> Result, String> { + match self { + SecretKey::Value(value) => Ok(Cow::Borrowed(value.secret())), + SecretKey::File(file) => file.secret().await.map(Cow::Owned), + SecretKey::EnvironmentVariable(env_var) => env_var.secret().map(Cow::Owned), + } + } +} + +impl SecretText { + pub async fn secret(&self) -> Result, String> { + match self { + SecretText::Text(value) => Ok(Cow::Borrowed(value.secret())), + SecretText::File(file) => file.secret().await.map(Cow::Owned), + SecretText::EnvironmentVariable(env_var) => env_var.secret().map(Cow::Owned), + } + } +} + +impl SecretKeyOptional { + pub async fn secret(&self) -> Result>, String> { + match self { + SecretKeyOptional::None => Ok(None), + SecretKeyOptional::Value(secret_key_value) => { + Ok(Some(Cow::Borrowed(secret_key_value.secret()))) + } + SecretKeyOptional::EnvironmentVariable(secret_key_environment_variable) => { + secret_key_environment_variable + .secret() + .map(|s| Some(Cow::Owned(s))) + } + SecretKeyOptional::File(secret_key_file) => { + secret_key_file.secret().await.map(|s| Some(Cow::Owned(s))) + } + } + } +} + +impl SecretTextOptional { + pub async fn secret(&self) -> Result>, String> { + match self { + SecretTextOptional::None => Ok(None), + SecretTextOptional::Text(secret_text_value) => { + Ok(Some(Cow::Borrowed(secret_text_value.secret()))) + } + SecretTextOptional::EnvironmentVariable(secret_text_environment_variable) => { + secret_text_environment_variable + .secret() + .map(|s| Some(Cow::Owned(s))) + } + SecretTextOptional::File(secret_text_file) => { + secret_text_file.secret().await.map(|s| Some(Cow::Owned(s))) + } + } + } +} + +impl SecretKeyValue { + pub fn secret(&self) -> &str { + self.secret.as_str() + } +} + +impl SecretTextValue { + pub fn secret(&self) -> &str { + self.secret.as_str() + } +} + +impl SecretKeyFile { + pub async fn secret(&self) -> Result { + let path = self.file_path.trim(); + if !path.is_empty() { + tokio::fs::read_to_string(path) + .await + .map_err(|err| format!("Failed to read secret from file '{}': {}", path, err)) + .and_then(|content| { + let secret = content.trim_end(); + if !secret.is_empty() { + Ok(secret.to_string()) + } else { + Err(format!("Secret in file '{}' is empty", path)) + } + }) + } else { + Err("File path cannot be empty".to_string()) + } + } +} + +impl SecretKeyEnvironmentVariable { + pub fn secret(&self) -> Result { + let var = self.variable_name.trim(); + if !var.is_empty() { + std::env::var(var) + .ok() + .filter(|v| !v.is_empty()) + .ok_or_else(|| format!("Environment variable '{}' not found", var)) + } else { + Err("Variable name cannot be empty".to_string()) + } + } +} diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs index 64175a33..016fd9eb 100644 --- a/crates/services/src/broadcast/mod.rs +++ b/crates/services/src/broadcast/mod.rs @@ -105,6 +105,8 @@ impl BroadcastBatch> { CacheInvalidation::Tenant(id) => (5u8, *id), CacheInvalidation::Role(id) => (6u8, *id), CacheInvalidation::List(id) => (7u8, *id), + CacheInvalidation::DomainLogo(id) => (8u8, *id), + CacheInvalidation::TenantLogo(id) => (9u8, *id), }; serialized.push(marker); @@ -216,6 +218,8 @@ where 5 => CacheInvalidation::Tenant(id), 6 => CacheInvalidation::Role(id), 7 => CacheInvalidation::List(id), + 8 => CacheInvalidation::DomainLogo(id), + 9 => CacheInvalidation::TenantLogo(id), _ => return Err(()), }); } diff --git a/crates/services/src/broadcast/subscriber.rs b/crates/services/src/broadcast/subscriber.rs index 7d848250..f78c2db6 100644 --- a/crates/services/src/broadcast/subscriber.rs +++ b/crates/services/src/broadcast/subscriber.rs @@ -137,7 +137,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc, mut shutdown_rx: watch::Rec } } BroadcastEvent::CacheInvalidation(changes) => { - inner.build_server().invalidate_caches(changes, false).await; + inner.build_server().invalidate_local_caches(&changes).await; } BroadcastEvent::RegistryChange(change) => { diff --git a/crates/services/src/housekeeper/mod.rs b/crates/services/src/housekeeper/mod.rs index f05ee5e8..a8e6ba1b 100644 --- a/crates/services/src/housekeeper/mod.rs +++ b/crates/services/src/housekeeper/mod.rs @@ -514,7 +514,7 @@ pub fn spawn_housekeeper(inner: Arc, mut rx: mpsc::Receiver { Collector::update_gauge( MetricType::UserCount, - total, + total as u64, ); } Err(err) => { @@ -530,7 +530,7 @@ pub fn spawn_housekeeper(inner: Arc, mut rx: mpsc::Receiver { Collector::update_gauge( MetricType::DomainCount, - total, + total as u64, ); } Err(err) => { diff --git a/crates/services/src/task_manager/mod.rs b/crates/services/src/task_manager/mod.rs index dcb5f763..26c1c9cb 100644 --- a/crates/services/src/task_manager/mod.rs +++ b/crates/services/src/task_manager/mod.rs @@ -547,7 +547,7 @@ pub fn next_retry_time( TaskRetryStrategy::FixedDelay(fixed) => fixed.delay.as_secs(), TaskRetryStrategy::ExponentialBackoff(backoff) => { let delay = (backoff.initial_delay.as_secs() as f64 - * backoff.factor.powi(attempt as i32)) + * backoff.factor.into_inner().powi(attempt as i32)) .min(backoff.max_delay.as_secs() as f64) as u64; if backoff.jitter { diff --git a/crates/smtp/src/reporting/analysis.rs b/crates/smtp/src/reporting/analysis.rs index 6fc4890e..0ffd4c33 100644 --- a/crates/smtp/src/reporting/analysis.rs +++ b/crates/smtp/src/reporting/analysis.rs @@ -281,7 +281,7 @@ impl AnalyzeReport for Server { from, to, subject, - member_tenant_id: vec![], + member_tenant_id: None, expires_at: UTCDateTime::from_timestamp(expires as i64), received_at: UTCDateTime::now(), report: report.into(), @@ -312,7 +312,7 @@ impl AnalyzeReport for Server { from, to, subject, - member_tenant_id: vec![], + member_tenant_id: None, expires_at: UTCDateTime::from_timestamp(expires as i64), received_at: UTCDateTime::now(), report: report.into(), @@ -343,7 +343,7 @@ impl AnalyzeReport for Server { from, to, subject, - member_tenant_id: vec![], + member_tenant_id: None, expires_at: UTCDateTime::from_timestamp(expires as i64), received_at: UTCDateTime::now(), report: report.into(), @@ -384,7 +384,7 @@ impl AnalyzeReport for Server { } } -async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Vec { +async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Option { let mut tenant_ids = Vec::with_capacity(domains.len()); for domain in domains { if let Some(tenant_id) = server @@ -404,7 +404,12 @@ async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Vec { tenant_ids.push(tenant_id); } } - tenant_ids + + if tenant_ids.len() == 1 { + tenant_ids.into_iter().next() + } else { + None + } } trait LogReport { diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs index 67850f56..312040fe 100644 --- a/crates/smtp/src/reporting/dmarc.rs +++ b/crates/smtp/src/reporting/dmarc.rs @@ -600,7 +600,7 @@ impl DmarcReporting for Server { policy_subdomain_disposition: policy.sp.into(), policy_testing_mode: policy.testing, policy_version: None, - version: 1.0, + version: 1.0.into(), ..Default::default() }, policy_identifier: policy_hash, diff --git a/crates/store/src/backend/azure/mod.rs b/crates/store/src/backend/azure/mod.rs index ae1b4686..f1e4b1d7 100644 --- a/crates/store/src/backend/azure/mod.rs +++ b/crates/store/src/backend/azure/mod.rs @@ -23,7 +23,10 @@ pub struct AzureStore { impl AzureStore { pub async fn open(config: structs::AzureStore) -> Result { - let credentials = match (config.access_key, config.sas_token) { + let credentials = match ( + config.access_key.secret().await?.map(|v| v.into_owned()), + config.sas_token.secret().await?.map(|v| v.into_owned()), + ) { (Some(access_key), None) => { StorageCredentials::access_key(config.storage_account.clone(), access_key) } diff --git a/crates/store/src/backend/elastic/main.rs b/crates/store/src/backend/elastic/main.rs index 10ffef56..bf55282a 100644 --- a/crates/store/src/backend/elastic/main.rs +++ b/crates/store/src/backend/elastic/main.rs @@ -23,12 +23,15 @@ impl ElasticSearchStore { Url::parse(&config.url).map_err(|e| format!("Invalid URL: {e}",))?; Ok(SearchStore::ElasticSearch(Arc::new(Self { - client: config.http_auth.build_http_client( - config.http_headers, - "application/json".into(), - config.timeout, - config.allow_invalid_certs, - )?, + client: config + .http_auth + .build_http_client( + config.http_headers, + "application/json".into(), + config.timeout, + config.allow_invalid_certs, + ) + .await?, url: config.url, num_replicas: config.num_replicas as usize, num_shards: config.num_shards as usize, diff --git a/crates/store/src/backend/meili/main.rs b/crates/store/src/backend/meili/main.rs index c94e2a1f..277e86e7 100644 --- a/crates/store/src/backend/meili/main.rs +++ b/crates/store/src/backend/meili/main.rs @@ -19,12 +19,15 @@ use std::{sync::Arc, time::Duration}; impl MeiliSearchStore { pub async fn open(config: structs::MeilisearchStore) -> Result { - let client = config.http_auth.build_http_client( - config.http_headers, - "application/json".into(), - config.timeout, - config.allow_invalid_certs, - )?; + let client = config + .http_auth + .build_http_client( + config.http_headers, + "application/json".into(), + config.timeout, + config.allow_invalid_certs, + ) + .await?; Url::parse(&config.url).map_err(|e| format!("Invalid URL: {e}",))?; diff --git a/crates/store/src/backend/mysql/main.rs b/crates/store/src/backend/mysql/main.rs index 8b14ce8a..2acc56a6 100644 --- a/crates/store/src/backend/mysql/main.rs +++ b/crates/store/src/backend/mysql/main.rs @@ -23,7 +23,7 @@ impl MysqlStore { let mut opts = OptsBuilder::default() .ip_or_hostname(config.host) .user(config.auth_username) - .pass(config.auth_secret) + .pass(config.auth_secret.secret().await?.map(|v| v.into_owned())) .db_name(Some(config.database)) .max_allowed_packet(config.max_allowed_packet.map(|v| v as usize)) .wait_timeout(config.timeout.map(|t| t.as_secs() as usize)) @@ -58,7 +58,7 @@ impl MysqlStore { opts.clone() .ip_or_hostname(replica.host) .user(replica.auth_username) - .pass(replica.auth_secret) + .pass(replica.auth_secret.secret().await?.map(|v| v.into_owned())) .db_name(Some(replica.database)) .tcp_port(replica.port as u16), ), diff --git a/crates/store/src/backend/mysql/read.rs b/crates/store/src/backend/mysql/read.rs index d36aa9cc..46b5b97b 100644 --- a/crates/store/src/backend/mysql/read.rs +++ b/crates/store/src/backend/mysql/read.rs @@ -23,7 +23,7 @@ impl MysqlStore { .await .map_err(into_error)?; let key = key.serialize(0); - conn.exec_first::, _, _>(&s, (key,)) + conn.exec_first::, _, _>(&s, (&key,)) .await .map_err(into_error) .and_then(|r| { diff --git a/crates/store/src/backend/postgres/main.rs b/crates/store/src/backend/postgres/main.rs index aa045b9c..bc864ff0 100644 --- a/crates/store/src/backend/postgres/main.rs +++ b/crates/store/src/backend/postgres/main.rs @@ -25,7 +25,7 @@ impl PostgresStore { cfg.dbname = config.database.into(); cfg.host = config.host.into(); cfg.user = config.auth_username; - cfg.password = config.auth_secret; + cfg.password = config.auth_secret.secret().await?.map(|v| v.into_owned()); cfg.port = (config.port as u16).into(); cfg.connect_timeout = config.timeout.map(|t| t.into_inner()); cfg.options = config.options; @@ -47,7 +47,7 @@ impl PostgresStore { cfg.dbname = replica.database.into(); cfg.host = replica.host.into(); cfg.user = replica.auth_username; - cfg.password = replica.auth_secret; + cfg.password = replica.auth_secret.secret().await?.map(|v| v.into_owned()); cfg.port = (replica.port as u16).into(); cfg.options = replica.options; replicas.push(Store::PostgreSQL(Arc::new(PostgresStore { diff --git a/crates/store/src/backend/redis/mod.rs b/crates/store/src/backend/redis/mod.rs index 7c4dc7cb..f32d1dd7 100644 --- a/crates/store/src/backend/redis/mod.rs +++ b/crates/store/src/backend/redis/mod.rs @@ -64,7 +64,7 @@ impl RedisStore { if let Some(value) = config.auth_username { builder = builder.username(value); } - if let Some(value) = config.auth_secret { + if let Some(value) = config.auth_secret.secret().await?.map(|v| v.into_owned()) { builder = builder.password(value); } if let Some(value) = config.max_retries { diff --git a/crates/store/src/backend/s3/mod.rs b/crates/store/src/backend/s3/mod.rs index 7a9e18c9..9c1f7ad4 100644 --- a/crates/store/src/backend/s3/mod.rs +++ b/crates/store/src/backend/s3/mod.rs @@ -69,9 +69,9 @@ impl S3Store { }; let credentials = Credentials::new( config.access_key.as_deref(), - config.secret_key.as_deref(), - config.security_token.as_deref(), - config.session_token.as_deref(), + config.secret_key.secret().await?.as_deref(), + config.security_token.secret().await?.as_deref(), + config.session_token.secret().await?.as_deref(), config.profile.as_deref(), ) .map_err(|err| format!("Failed to create credentials: {err:?}"))?; diff --git a/crates/store/src/registry/query.rs b/crates/store/src/registry/query.rs index 401ec5f1..db62b958 100644 --- a/crates/store/src/registry/query.rs +++ b/crates/store/src/registry/query.rs @@ -142,6 +142,10 @@ impl RegistryStore { Ok(results) } + + pub async fn count(&self, query: RegistryQuery) -> trc::Result { + self.query::>(query).await.map(|r| r.len()) + } } pub trait RegistryQueryResults: Default + Sized + Sync + Send { diff --git a/crates/store/src/registry/write.rs b/crates/store/src/registry/write.rs index f92f44b9..2c2c9d52 100644 --- a/crates/store/src/registry/write.rs +++ b/crates/store/src/registry/write.rs @@ -49,18 +49,10 @@ pub enum RegistryWriteResult { ValidationError { errors: Vec, }, - InvalidTenantId, - InvalidAccountId, NotSupported, } -pub struct RegistryWrite<'x> { - op: RegistryWriteOp<'x>, - current_tenant_id: Option, - current_account_id: Option, -} - -pub enum RegistryWriteOp<'x> { +pub enum RegistryWrite<'x> { Insert { object: &'x Object, id: Option, @@ -85,15 +77,14 @@ impl RegistryStore { let object_type; let object_flags; let object_id; - let object_tenant_id; let mut item_id; let mut batch = BatchBuilder::new(); let mut write_id = true; let mut generate_id = false; - match write.op { - RegistryWriteOp::Insert { + match write { + RegistryWrite::Insert { object: insert_object, id, } => { @@ -102,7 +93,6 @@ impl RegistryStore { object_type = object.object_type(); object_id = object_type.to_id(); object.index(&mut set_index); - object_tenant_id = set_index.tenant_id(); item_id = if let Some(id) = id { id.id() @@ -116,7 +106,7 @@ impl RegistryStore { self.0.id_generator.generate() }; } - RegistryWriteOp::Update { + RegistryWrite::Update { object: update_object, id, old_object, @@ -126,7 +116,6 @@ impl RegistryStore { object_type = object.object_type(); object_id = object_type.to_id(); object.index(&mut set_index); - object_tenant_id = set_index.tenant_id(); // Obtain changes let mut old_index = IndexBuilder::default(); @@ -148,9 +137,9 @@ impl RegistryStore { AssertValue::Hash(old_object.revision), ); } - RegistryWriteOp::Delete { object_id, object } => { + RegistryWrite::Delete { object_id, object } => { return if object_id.object().flags() & OBJ_SINGLETON == 0 { - self.delete(write, object_id, object).await + self.delete(object_id, object).await } else { Ok(RegistryWriteResult::CannotDeleteSingleton) }; @@ -164,19 +153,6 @@ impl RegistryStore { return Ok(RegistryWriteResult::ValidationError { errors }); } - // Validate tenant ownership - if write.current_tenant_id.is_some() - && (object_flags & OBJ_FILTER_TENANT) != 0 - && write.current_tenant_id != object_tenant_id - { - return Ok(RegistryWriteResult::InvalidTenantId); - } - - // Validate tenant and account changes - if let Some(err) = write.validate_owner(&set_index) { - return Ok(err); - } - // Write to local registry if self.0.local_objects.contains(&object_type) { if generate_id { @@ -194,6 +170,8 @@ impl RegistryStore { } // Validate foreign keys + let tenant_id = object.inner.member_tenant_id().map(|id| id.id()); + let account_id = object.inner.account_id().map(|id| id.id()); for key in &set_index.keys { match key { IndexKey::ForeignKey { @@ -224,7 +202,7 @@ impl RegistryStore { return Ok(RegistryWriteResult::InvalidForeignKey { object_id: *foreign_id, }); - } else if let Some(tenant_id) = object_tenant_id + } else if let Some(tenant_id) = tenant_id && (object_flags & OBJ_FILTER_TENANT) != 0 && self .0 @@ -234,7 +212,7 @@ impl RegistryStore { index_id: Property::MemberTenantId.to_id(), object_id, item_id, - key: IndexValue::U64(tenant_id as u64).serialize(), + key: IndexValue::U64(tenant_id).serialize(), }, ))) .await @@ -244,7 +222,7 @@ impl RegistryStore { return Ok(RegistryWriteResult::InvalidForeignKey { object_id: *foreign_id, }); - } else if let Some(account_id) = write.current_account_id + } else if let Some(account_id) = account_id && (object_flags & OBJ_FILTER_ACCOUNT) != 0 && self .0 @@ -254,7 +232,7 @@ impl RegistryStore { index_id: Property::AccountId.to_id(), object_id, item_id, - key: IndexValue::U64(account_id as u64).serialize(), + key: IndexValue::U64(account_id).serialize(), }, ))) .await @@ -334,7 +312,6 @@ impl RegistryStore { async fn delete( &self, - write: RegistryWrite<'_>, object_id: ObjectId, object: Option<&Object>, ) -> trc::Result { @@ -368,11 +345,47 @@ impl RegistryStore { // Validate tenant and account changes let mut clear_index = IndexBuilder::default(); object.index(&mut clear_index); - if let Some(err) = write.validate_owner(&clear_index) { - return Ok(err); - } // Validate relationships + let linked = self.linked_objects(object_id).await?; + if !linked.is_empty() { + return Ok(RegistryWriteResult::CannotDeleteLinked { + object_id: ObjectId::new(object_type, id), + linked_objects: linked, + }); + } + + // Build deletion batch + let mut batch = BatchBuilder::new(); + batch + .assert_value( + ValueClass::Registry(RegistryClass::Item { + object_id: object_type_id, + item_id, + }), + AssertValue::Hash(object.revision), + ) + .clear(ValueClass::Registry(RegistryClass::Item { + object_id: object_type_id, + item_id, + })) + .clear(ValueClass::Registry(RegistryClass::IndexId { + object_id: object_type_id, + item_id, + })) + .registry_index(object_type_id, item_id, clear_index.keys.iter(), false); + + self.0 + .store + .write(batch.build_all()) + .await + .map(|_| RegistryWriteResult::Success(Id::from(item_id))) + .caused_by(trc::location!()) + } + + pub async fn linked_objects(&self, object_id: ObjectId) -> trc::Result> { + let object_type_id = object_id.object().to_id(); + let item_id = object_id.id().id(); let mut linked = Vec::new(); let from_key = ValueKey::from(ValueClass::Registry(RegistryClass::Reference { to_object_id: object_type_id, @@ -411,41 +424,13 @@ impl RegistryStore { }, ) .await - .caused_by(trc::location!())?; - - if !linked.is_empty() { - return Ok(RegistryWriteResult::CannotDeleteLinked { - object_id: ObjectId::new(object_type, id), - linked_objects: linked, - }); - } - - // Build deletion batch - let mut batch = BatchBuilder::new(); - batch - .assert_value( - ValueClass::Registry(RegistryClass::Item { - object_id: object_type_id, - item_id, - }), - AssertValue::Hash(object.revision), - ) - .clear(ValueClass::Registry(RegistryClass::Item { - object_id: object_type_id, - item_id, - })) - .clear(ValueClass::Registry(RegistryClass::IndexId { - object_id: object_type_id, - item_id, - })) - .registry_index(object_type_id, item_id, clear_index.keys.iter(), false); - - self.0 - .store - .write(batch.build_all()) - .await - .map(|_| RegistryWriteResult::Success(Id::from(item_id))) .caused_by(trc::location!()) + .map(|_| linked) + } + + #[inline(always)] + pub fn assign_id(&self) -> u64 { + self.0.id_generator.generate() } } @@ -538,117 +523,37 @@ impl SerializeInfallible for IndexValue<'_> { impl<'x> RegistryWrite<'x> { pub fn insert(object: &'x Object) -> Self { - Self { - op: RegistryWriteOp::Insert { object, id: None }, - current_tenant_id: None, - current_account_id: None, - } + RegistryWrite::Insert { object, id: None } } pub fn insert_with_id(id: Id, object: &'x Object) -> Self { - Self { - op: RegistryWriteOp::Insert { - object, - id: Some(id), - }, - current_tenant_id: None, - current_account_id: None, + RegistryWrite::Insert { + object, + id: Some(id), } } pub fn update(id: Id, object: &'x Object, old_object: &'x Object) -> Self { - Self { - op: RegistryWriteOp::Update { - object, - id, - old_object, - }, - current_tenant_id: None, - current_account_id: None, + RegistryWrite::Update { + object, + id, + old_object, } } pub fn delete(object_id: ObjectId) -> Self { - Self { - op: RegistryWriteOp::Delete { - object_id, - object: None, - }, - current_tenant_id: None, - current_account_id: None, + RegistryWrite::Delete { + object_id, + object: None, } } pub fn delete_object(object_id: ObjectId, object: &'x Object) -> Self { - Self { - op: RegistryWriteOp::Delete { - object_id, - object: Some(object), - }, - current_tenant_id: None, - current_account_id: None, + RegistryWrite::Delete { + object_id, + object: Some(object), } } - - pub fn with_current_tenant_id(mut self, tenant_id: u32) -> Self { - self.current_tenant_id = Some(tenant_id); - self - } - - pub fn with_current_account_id(mut self, account_id: u32) -> Self { - self.current_account_id = Some(account_id); - self - } - - fn validate_owner(&self, builder: &IndexBuilder<'_>) -> Option { - // Validate tenant and account changes - if let Some(tenant_id) = self.current_tenant_id { - for key in &builder.keys { - if let IndexKey::Search { - property: Property::MemberTenantId, - value, - } = key - && value != &IndexValue::U64(tenant_id as u64) - { - return Some(RegistryWriteResult::InvalidTenantId); - } - } - } - if let Some(account_id) = self.current_account_id { - for key in &builder.keys { - if let IndexKey::Search { - property: Property::AccountId, - value, - } = key - && value != &IndexValue::U64(account_id as u64) - { - return Some(RegistryWriteResult::InvalidTenantId); - } - } - } - - None - } -} - -trait FindTenantId { - fn tenant_id(&self) -> Option; -} - -impl FindTenantId for IndexBuilder<'_> { - fn tenant_id(&self) -> Option { - self.keys.iter().find_map(|key| { - if let IndexKey::Search { - property: Property::MemberTenantId, - value: IndexValue::U64(tenant_id), - } = key - { - Some(*tenant_id as u32) - } else { - None - } - }) - } } impl Display for RegistryWriteResult { @@ -689,8 +594,6 @@ impl Display for RegistryWriteResult { } Ok(()) } - RegistryWriteResult::InvalidTenantId => write!(f, "Invalid tenant id"), - RegistryWriteResult::InvalidAccountId => write!(f, "Invalid account id"), RegistryWriteResult::NotSupported => write!(f, "Operation not supported"), } } diff --git a/crates/trc/src/ipc/bitset.rs b/crates/trc/src/ipc/bitset.rs index 05fed94d..716ccd09 100644 --- a/crates/trc/src/ipc/bitset.rs +++ b/crates/trc/src/ipc/bitset.rs @@ -55,6 +55,12 @@ impl Bitset { } } + pub fn clear_many(&mut self, other: &Self) { + for i in 0..N { + self.0[i] &= !other.0[i]; + } + } + pub fn clear_all(&mut self) { for i in 0..N { self.0[i] = 0;