diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 902ffbfe..2eed0ce2 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, permissions::build_permissions_list, + permissions::{BuildPermissions, build_permissions_list}, }, network::limiter::{ConcurrencyLimiter, LimiterResult}, }; @@ -23,6 +23,7 @@ use registry::{ }; use std::{ hash::{Hash, Hasher}, + net::IpAddr, sync::Arc, }; use store::{query::acl::AclQuery, rand, write::now}; @@ -110,41 +111,63 @@ impl Server { } let now = now(); - let permissions = permissions.finalize(); - let credential_scopes = account - .credentials - .into_iter() - .filter_map(|(credential_id, credential)| { - let credential = credential.unwrap_properties(); - let expires_at = credential - .expires_at - .map(|v| v.timestamp() as u64) - .unwrap_or(u64::MAX); - if expires_at > now { - let permissions = match credential.permissions { - structs::Permissions::Inherit => permissions.clone(), - structs::Permissions::Merge(merge) => { - let mut permissions = permissions.clone(); - permissions.clear_many(&PermissionsGroup::from(merge).disabled); - permissions - } - structs::Permissions::Replace(replace) => { - let mut replace_permissions = - PermissionsGroup::from(replace).finalize(); - replace_permissions.intersection(&permissions); - replace_permissions - } - }; - Some(AccessScope { - credential_id, - permissions, - expires_at, - }) - } else { - None + let mut credential_scopes = Vec::with_capacity(account.credentials.len()); + + credential_scopes.push(AccessScope::new(permissions.finalize(), u32::MAX)); + + for credential in account.credentials { + match credential { + structs::Credential::Password(credential) => { + if credential.expires_at.is_some() || !credential.allowed_ips.is_empty() + { + let credential_scope = &mut credential_scopes[0]; + credential_scope.expires_at = credential + .expires_at + .map(|v| v.timestamp() as u64) + .unwrap_or(u64::MAX); + credential_scope.allowed_ips = + credential.allowed_ips.into_inner().into_boxed_slice(); + } } - }) - .collect::>(); + structs::Credential::ApiKey(credential) + | structs::Credential::AppPassword(credential) => { + let credential_id = credential.credential_id.document_id(); + let expires_at = credential + .expires_at + .map(|v| v.timestamp() as u64) + .unwrap_or(u64::MAX); + if expires_at > now { + let permissions = &credential_scopes[0].permissions; + let permissions = match credential.permissions { + structs::CredentialPermissions::Inherit => permissions.clone(), + structs::CredentialPermissions::Disable(list) => { + let mut permissions = permissions.clone(); + permissions.clear_many(&Permissions::from_permission( + list.permissions.as_slice(), + )); + permissions + } + structs::CredentialPermissions::Replace(list) => { + let mut replace_permissions = Permissions::from_permission( + list.permissions.as_slice(), + ); + replace_permissions.intersection(permissions); + replace_permissions + } + }; + credential_scopes.push(AccessScope { + credential_id, + permissions, + expires_at, + allowed_ips: credential + .allowed_ips + .into_inner() + .into_boxed_slice(), + }) + } + } + } + } Ok(AccessTokenInner { concurrent_imap_requests: self @@ -169,7 +192,7 @@ impl Server { tenant_id, member_of, access_to: access_to.into_boxed_slice(), - scopes: [AccessScope::new(permissions, u32::MAX)] + scopes: [] .into_iter() .chain(credential_scopes) .collect::>(), @@ -306,7 +329,11 @@ impl AccessToken { } } - pub fn scoped(inner: Arc, credential_id: u32) -> trc::Result { + pub fn scoped( + inner: Arc, + credential_id: u32, + remote_ip: IpAddr, + ) -> trc::Result { inner .scopes .iter() @@ -319,12 +346,16 @@ impl AccessToken { .reason("Credential expired or removed.") }) .map(|scope_idx| AccessToken { scope_idx, inner }) - .and_then(|token| token.assert_is_valid()) + .and_then(|token| token.assert_is_valid(remote_ip)) } - pub fn renew(inner: Arc, credential_id: Option) -> trc::Result { + pub fn renew( + inner: Arc, + credential_id: Option, + remote_ip: IpAddr, + ) -> trc::Result { if let Some(credential_id) = credential_id { - Self::scoped(inner, credential_id) + Self::scoped(inner, credential_id, remote_ip) } else { Ok(AccessToken { scope_idx: 0, @@ -402,14 +433,26 @@ impl AccessToken { .is_some_and(|scope| scope.permissions.get(permission as usize)) } - pub fn assert_is_valid(self) -> trc::Result { - if self + pub fn assert_is_valid(self, remote_ip: IpAddr) -> trc::Result { + if let Some(scope) = self .inner .scopes .get(self.scope_idx) - .is_some_and(|scope| scope.expires_at > now()) + .filter(|scope| scope.expires_at > now()) { - Ok(self) + if scope.allowed_ips.is_empty() + || scope + .allowed_ips + .iter() + .any(|ip_mask| ip_mask.matches(&remote_ip)) + { + Ok(self) + } else { + Err(trc::SecurityEvent::Unauthorized + .into_err() + .ctx(trc::Key::AccountId, self.inner.account_id) + .reason("IP address not allowed.")) + } } else { Err(trc::SecurityEvent::Unauthorized .into_err() @@ -631,6 +674,7 @@ impl AccessScope { permissions, credential_id, expires_at: u64::MAX, + allowed_ips: Default::default(), } } } @@ -644,15 +688,18 @@ fn hash_account(account: &Account) -> u64 { match &account.roles { Roles::Default => {} Roles::Custom(custom_roles) => { - custom_roles.role_ids.hash(&mut s); + custom_roles.role_ids.as_slice().hash(&mut s); } } hash_permissions(&mut s, &account.permissions); - for (credential_id, credential) in &account.credentials { - let credential = credential.as_properties(); - credential_id.hash(&mut s); + for credential in account + .credentials + .iter() + .filter_map(|credential| credential.as_secondary_credential()) + { + credential.credential_id.hash(&mut s); credential.expires_at.hash(&mut s); - hash_permissions(&mut s, &credential.permissions); + hash_credential_permissions(&mut s, &credential.permissions); } } Account::Group(account) => { @@ -660,7 +707,7 @@ fn hash_account(account: &Account) -> u64 { match &account.roles { Roles::Default => {} Roles::Custom(custom_roles) => { - custom_roles.role_ids.hash(&mut s); + custom_roles.role_ids.as_slice().hash(&mut s); } } hash_permissions(&mut s, &account.permissions); @@ -677,19 +724,29 @@ fn hash_permissions(hasher: &mut AHasher, permissions: &structs::Permissions) { } structs::Permissions::Merge(permissions) => { 2u8.hash(hasher); - for (perm, enabled) in permissions.permissions.iter() { - if *enabled { - perm.hash(hasher); - } - } + permissions.enabled_permissions.as_slice().hash(hasher); + permissions.disabled_permissions.as_slice().hash(hasher); } structs::Permissions::Replace(permissions) => { 3u8.hash(hasher); - for (perm, enabled) in permissions.permissions.iter() { - if *enabled { - perm.hash(hasher); - } - } + permissions.enabled_permissions.as_slice().hash(hasher); + permissions.disabled_permissions.as_slice().hash(hasher); + } + } +} + +fn hash_credential_permissions(hasher: &mut AHasher, permissions: &structs::CredentialPermissions) { + match permissions { + structs::CredentialPermissions::Inherit => { + 0u8.hash(hasher); + } + structs::CredentialPermissions::Disable(permissions) => { + 2u8.hash(hasher); + permissions.permissions.as_slice().hash(hasher); + } + structs::CredentialPermissions::Replace(permissions) => { + 3u8.hash(hasher); + permissions.permissions.as_slice().hash(hasher); } } } diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 35872251..460c2357 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -128,6 +128,7 @@ impl Server { account_id, app_pass.credential_id, app_pass.secret.as_ref(), + req.remote_ip, req.session_id, ) .await @@ -160,15 +161,30 @@ impl Server { .await? .and_then(|account| account.into_user()) { - if verify_mfa_secret_hash( - account.otp_auth.as_deref(), - account.secret.as_str(), - secret, - ) - .await? + if let Some(credential) = account.password_credential() + && verify_mfa_secret_hash( + credential.otp_auth.as_deref(), + credential.secret.as_str(), + secret, + ) + .await? { - is_alias_login = account.name != auth_as_address; - self.access_token(account_id).await.map(AccessToken::new) + if credential + .expires_at + .as_ref() + .is_none_or(|exp| exp.timestamp() > now() as i64) + { + is_alias_login = account.name != auth_as_address; + self.access_token(account_id).await.map(AccessToken::new) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, account.name.to_string()) + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential.credential_id.id()) + .ctx(trc::Key::SpanId, req.session_id) + .reason("Password credential has expired")) + } } else { Err(trc::AuthEvent::Failed .into_err() @@ -245,6 +261,7 @@ impl Server { key.account_id, key.credential_id, key.secret.as_ref(), + req.remote_ip, req.session_id, ) .await; @@ -296,6 +313,7 @@ impl Server { account_id: u32, credential_id: u32, secret: &[u8], + remote_ip: IpAddr, span_id: u64, ) -> trc::Result { if let Some(account) = self @@ -305,10 +323,15 @@ impl Server { .and_then(|account| account.into_user()) { // Find credential by credential_id - for (id, credential_) in &account.credentials { - let credential = credential_.as_properties(); - - if *id == credential_id { + let mut authenticated = false; + for (credential, credential_type) in + account.credentials.iter().filter_map(|credential| { + credential + .as_secondary_credential() + .map(|secondary_credential| (secondary_credential, credential)) + }) + { + if credential.credential_id.document_id() == credential_id { if !verify_secret_hash(&credential.secret, secret).await? { return Err(trc::AuthEvent::Failed .into_err() @@ -339,27 +362,33 @@ impl Server { AccountId = account_id, Id = credential_id, SpanId = span_id, - Details = match credential_ { + Details = match credential_type { Credential::AppPassword(_) => "Authenticated with app password", Credential::ApiKey(_) => "Authenticated with API key", + _ => "Authenticated with credential", } ); - let token = self - .access_token_from_account(account_id, structs::Account::User(account)) - .await?; - - return AccessToken::scoped(token, credential_id) - .add_context(|ctx| ctx.span_id(span_id)); + authenticated = true; + break; } } - Err(trc::AuthEvent::Failed - .into_err() - .ctx(trc::Key::AccountId, account_id) - .ctx(trc::Key::Id, credential_id) - .ctx(trc::Key::SpanId, span_id) - .reason("Credential not found for account")) + if authenticated { + let token = self + .access_token_from_account(account_id, structs::Account::User(account)) + .await?; + + AccessToken::scoped(token, credential_id, remote_ip) + .add_context(|ctx| ctx.span_id(span_id)) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountId, account_id) + .ctx(trc::Key::Id, credential_id) + .ctx(trc::Key::SpanId, span_id) + .reason("Credential not found for account")) + } } else { Err(trc::AuthEvent::Failed .into_err() diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 07b5afb6..0ec52c4f 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -14,7 +14,7 @@ use directory::Credentials; use quick_cache::Equivalent; use registry::{ schema::enums::{Locale, Permission}, - types::EnumImpl, + types::{EnumImpl, ipmask::IpAddrOrMask}, }; use std::{ hash::{Hash, Hasher}, @@ -136,6 +136,7 @@ pub(crate) struct AccessScope { pub permissions: Permissions, pub credential_id: u32, pub expires_at: u64, + pub allowed_ips: Box<[IpAddrOrMask]>, } #[derive(Debug, Default, Hash, PartialEq, Eq)] diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index b17a8bed..6a460b5d 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -221,7 +221,11 @@ impl Server { .object::(account_id.into()) .await .caused_by(trc::location!())? - .and_then(|account| account.into_user().map(|account| account.secret)) + .and_then(|account| { + account + .into_user() + .and_then(|account| account.into_password()) + }) .ok_or_else(|| { trc::AuthEvent::Error .into_err() diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index ef37226a..da333a5c 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -388,13 +388,17 @@ impl PermissionsGroup { impl From for PermissionsGroup { fn from(value: PermissionsList) -> Self { - PermissionsGroup::from(&value.permissions) + Self::from(&value) } } impl From<&PermissionsList> for PermissionsGroup { fn from(value: &PermissionsList) -> Self { - PermissionsGroup::from(&value.permissions) + PermissionsGroup { + enabled: Permissions::from_permission(value.enabled_permissions.as_slice()), + disabled: Permissions::from_permission(value.disabled_permissions.as_slice()), + merge: false, + } } } @@ -411,3 +415,17 @@ impl From<&VecMap> for PermissionsGroup { permissions } } + +pub trait BuildPermissions { + fn from_permission(list: &[Permission]) -> Permissions; +} + +impl BuildPermissions for Permissions { + fn from_permission(list: &[Permission]) -> Permissions { + let mut permission = Permissions::default(); + for p in list { + permission.set(*p as usize); + } + permission + } +} diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index 26851284..b558cc94 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -10,9 +10,11 @@ use crate::{Server, auth::DomainCache}; use registry::{ schema::{ prelude::{Object, ObjectType}, - structs::{Account, EmailAlias, GroupAccount, Roles, UserAccount}, + structs::{ + Account, Credential, EmailAlias, GroupAccount, PasswordCredential, Roles, UserAccount, + }, }, - types::{datetime::UTCDateTime, id::ObjectId}, + types::{datetime::UTCDateTime, id::ObjectId, list::List}, }; use store::registry::write::{RegistryWrite, RegistryWriteResult}; use trc::AddContext; @@ -60,10 +62,10 @@ impl Server { })?; let mut has_changes = false; if let Some(secret) = account.secret - && secret != updated_account.secret + && secret != updated_account.password().unwrap_or_default() { has_changes = true; - updated_account.secret = secret; + updated_account.set_password(secret); } if account.description.is_some() && account.description != updated_account.description @@ -107,7 +109,7 @@ impl Server { .iter() .all(|id| member_group_ids.contains(id))) { - updated_account.member_group_ids = member_group_ids; + updated_account.member_group_ids = member_group_ids.into(); has_changes = true; } @@ -173,13 +175,16 @@ impl Server { let account = Object::from(Account::User(UserAccount { name: local.to_string(), domain_id: Id::from(domain.id), - aliases, + aliases: aliases.into(), created_at: UTCDateTime::now(), description: account.description, - member_group_ids, + member_group_ids: member_group_ids.into(), member_tenant_id: domain.id_tenant.map(Id::from), roles: Roles::Default, - secret: account.secret.unwrap_or_default(), + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: account.secret.unwrap_or_default(), + ..Default::default() + })]), ..Default::default() })); @@ -313,7 +318,7 @@ impl Server { let account = Object::from(Account::Group(GroupAccount { name: local.to_string(), domain_id: Id::from(domain.id), - aliases, + aliases: aliases.into(), created_at: UTCDateTime::now(), description: group.description, member_tenant_id: domain.id_tenant.map(Id::from), diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index c51b3a5b..0ce9fea3 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -43,9 +43,7 @@ impl CacheInvalidationBuilder { 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; + let credentials_changed = current.credentials != new.credentials; if was_renamed || aliases_changed @@ -138,7 +136,8 @@ impl CacheInvalidationBuilder { } (ObjectInner::Role(current), ObjectInner::Role(new)) => { - if (current.permissions != new.permissions) + if (current.enabled_permissions != new.enabled_permissions) + || (current.disabled_permissions != new.disabled_permissions) || (current.member_tenant_id != new.member_tenant_id) || (current.role_ids != new.role_ids) { diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index 500197b7..7be59382 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -9,7 +9,7 @@ use crate::{ auth::{ AccountCache, AccountInfo, AccountTenantIds, DOMAIN_FLAG_RELAY, DOMAIN_FLAG_SUB_ADDRESSING, DomainCache, EmailAddress, EmailAddressRef, EmailCache, MailingListCache, PermissionsGroup, - RoleCache, TenantCache, + RoleCache, TenantCache, permissions::BuildPermissions, }, config::smtp::auth::DkimSigner, expr::if_block::BootstrapExprExt, @@ -23,8 +23,8 @@ use registry::{ enums::{Locale, StorageQuota, TenantStorageQuota}, prelude::{ObjectType, Property}, structs::{ - Account, DkimSignature, Domain, MailingList, MaskedEmail, Permissions, PermissionsList, - Role, SubAddressing, Tenant, + Account, DkimSignature, Domain, MailingList, MaskedEmail, Permissions, Role, + SubAddressing, Tenant, }, }, types::{EnumImpl, id::ObjectId}, @@ -502,9 +502,15 @@ impl Server { .into_iter() .map(|id| id.document_id()) .collect(), - permissions: PermissionsGroup::from(PermissionsList { - permissions: role.permissions, - }), + permissions: PermissionsGroup { + enabled: crate::auth::Permissions::from_permission( + role.enabled_permissions.as_slice(), + ), + disabled: crate::auth::Permissions::from_permission( + role.disabled_permissions.as_slice(), + ), + merge: false, + }, }); let _ = guard.insert(cache.clone()); diff --git a/crates/common/src/config/mailstore/capabilities.rs b/crates/common/src/config/mailstore/capabilities.rs index b566b322..2f3392d0 100644 --- a/crates/common/src/config/mailstore/capabilities.rs +++ b/crates/common/src/config/mailstore/capabilities.rs @@ -221,7 +221,7 @@ impl JmapConfig { max_redirects: sieve.max_redirects, extensions, notification_methods: if !sieve.allowed_notify_uris.is_empty() { - sieve.allowed_notify_uris.into() + sieve.allowed_notify_uris.into_inner().into() } else { None }, diff --git a/crates/common/src/config/mailstore/email.rs b/crates/common/src/config/mailstore/email.rs index 31cf680e..548d86cf 100644 --- a/crates/common/src/config/mailstore/email.rs +++ b/crates/common/src/config/mailstore/email.rs @@ -149,7 +149,7 @@ impl EmailConfig { }; default_folders.push(DefaultFolder { name: folder.name, - aliases: folder.aliases, + aliases: folder.aliases.into_inner(), special_use, subscribe: folder.subscribe, create: folder.create diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index d1ce8586..305a3b28 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -125,7 +125,7 @@ impl ContactForm { } Some(ContactForm { - rcpt_to: form.deliver_to, + rcpt_to: form.deliver_to.into_inner(), max_size: form.max_size as usize, validate_domain: form.validate_domain, from_email: FieldOrDefault { @@ -222,7 +222,7 @@ impl Network { roles.set_role( role_obj .node_ranges - .iter() + .values() .any(|range| range.contains(node_id)), ) } @@ -276,7 +276,7 @@ impl Network { if shard .object .node_ranges - .iter() + .values() .any(|range| range.contains(node_id)) { if matches!(roles, ClusterRole::Enabled) { @@ -397,8 +397,8 @@ impl AsnGeoLookupConfig { ) }) .ok()?, - asn_resources: asn.asn_urls, - geo_resources: asn.geo_urls, + asn_resources: asn.asn_urls.into_inner(), + geo_resources: asn.geo_urls.into_inner(), }), Asn::Dns(asn) => Some(AsnGeoLookupConfig::Dns { zone_ipv4: asn.zone_ip_v4, diff --git a/crates/common/src/config/server/listener.rs b/crates/common/src/config/server/listener.rs index 1554adfe..e644bb6e 100644 --- a/crates/common/src/config/server/listener.rs +++ b/crates/common/src/config/server/listener.rs @@ -41,7 +41,7 @@ impl Listeners { || listener .object .enable_for_nodes - .iter() + .values() .any(|n| n.contains(node_id)) { servers.parse_server(bp, listener); @@ -67,7 +67,7 @@ impl Listeners { // Build listeners let mut listeners = Vec::new(); - for addr in &listener.bind { + for addr in listener.bind.iter() { // Parse bind address and build socket let addr = addr.0; let socket = match if addr.is_ipv4() { @@ -131,9 +131,9 @@ impl Listeners { protocol, listeners, proxy_networks: if !listener.override_proxy_trusted_networks.is_empty() { - listener.override_proxy_trusted_networks.clone() + listener.override_proxy_trusted_networks.as_slice().to_vec() } else { - bp.node.proxy_trusted_networks.clone() + bp.node.proxy_trusted_networks.as_slice().to_vec() }, span_id_gen, }); diff --git a/crates/common/src/config/smtp/auth.rs b/crates/common/src/config/smtp/auth.rs index fd7c6c76..556711a1 100644 --- a/crates/common/src/config/smtp/auth.rs +++ b/crates/common/src/config/smtp/auth.rs @@ -338,7 +338,9 @@ fn build_dkim1_sealer>( .iter() .any(|h| h.eq_ignore_ascii_case("DKIM-Signature")) { - signature.headers.push("DKIM-Signature".to_string()); + signature + .headers + .push_unchecked("DKIM-Signature".to_string()); } let mut sealer = mail_auth::arc::ArcSealer::from_key(key) diff --git a/crates/common/src/config/smtp/queue.rs b/crates/common/src/config/smtp/queue.rs index 14e07359..ca3361ba 100644 --- a/crates/common/src/config/smtp/queue.rs +++ b/crates/common/src/config/smtp/queue.rs @@ -20,8 +20,8 @@ use registry::schema::{ prelude::ObjectType, structs::{ DsnReportSettings, MtaConnectionStrategy, MtaDeliveryExpiration, MtaDeliverySchedule, - MtaInboundThrottle, MtaOutboundStrategy, MtaOutboundThrottle, MtaQueueQuota, MtaRoute, - MtaTlsStrategy, MtaVirtualQueue, + MtaDeliveryScheduleIntervalsOrDefault, MtaInboundThrottle, MtaOutboundStrategy, + MtaOutboundThrottle, MtaQueueQuota, MtaRoute, MtaTlsStrategy, MtaVirtualQueue, }, }; use std::{ @@ -260,21 +260,36 @@ impl QueueConfig { ); continue; }; + queue.queue_strategy.insert( obj.object.name, QueueStrategy { - retry: obj - .object - .retry - .into_iter() - .map(|d| d.into_inner().as_secs()) - .collect(), - notify: obj - .object - .notify - .into_iter() - .map(|d| d.into_inner().as_secs()) - .collect(), + retry: match obj.object.retry { + MtaDeliveryScheduleIntervalsOrDefault::Default => vec![ + 2 * 60, + 5 * 60, + 10 * 60, + 15 * 60, + 30 * 60, + 60 * 60, + 2 * 60 * 60, + ], + MtaDeliveryScheduleIntervalsOrDefault::Custom(intervals) => intervals + .intervals + .into_iter() + .map(|d| d.duration.as_secs()) + .collect(), + }, + notify: match obj.object.notify { + MtaDeliveryScheduleIntervalsOrDefault::Default => { + vec![24 * 60 * 60, 3 * 24 * 60 * 60] + } + MtaDeliveryScheduleIntervalsOrDefault::Custom(intervals) => intervals + .intervals + .into_iter() + .map(|d| d.duration.as_secs()) + .collect(), + }, expiry: match obj.object.expiry { MtaDeliveryExpiration::Ttl(exp) => { QueueExpiry::Ttl(exp.expire.into_inner().as_secs()) diff --git a/crates/common/src/enterprise/config.rs b/crates/common/src/enterprise/config.rs index 22434095..ed3c4d71 100644 --- a/crates/common/src/enterprise/config.rs +++ b/crates/common/src/enterprise/config.rs @@ -199,7 +199,7 @@ impl Enterprise { method.push(AlertMethod::Email { from_name: alert.from_name, from_addr: alert.from_address, - to: alert.to, + to: alert.to.into_inner(), subject: AlertContent::new(&alert.subject), body: AlertContent::new(&alert.body), }); diff --git a/crates/common/src/expr/if_block.rs b/crates/common/src/expr/if_block.rs index eb75bd9b..8e70cd12 100644 --- a/crates/common/src/expr/if_block.rs +++ b/crates/common/src/expr/if_block.rs @@ -38,13 +38,13 @@ impl IfBlock { pub fn new_default(id: ObjectId, expr_ctx: ExpressionContext<'_>) -> Self { let token_map = TokenMap::default(); - if let Some(default) = &expr_ctx.default { + if let Some(default) = expr_ctx.default { Self { id, property: expr_ctx.property, if_then: default .match_ - .iter() + .into_iter() .map(|match_| IfThen { expr: Expression::parse(&token_map, &match_.if_), then: Expression::parse(&token_map, &match_.then), diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index 3a9cd6ce..e36e6916 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -143,9 +143,9 @@ impl Security { .map(|pattern| MatchType::Matches(GlobPattern::compile(pattern, true))) .collect(), scanner_fail_rate: security.scan_ban_rate, - 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, + default_role_ids_user: auth.default_user_role_ids.into_inner(), + default_role_ids_group: auth.default_group_role_ids.into_inner(), + default_role_ids_tenant: auth.default_tenant_role_ids.into_inner(), password_hash_algorithm: auth.password_hash_algorithm, } } diff --git a/crates/common/src/telemetry/tracers/store.rs b/crates/common/src/telemetry/tracers/store.rs index 68c777f8..117910fb 100644 --- a/crates/common/src/telemetry/tracers/store.rs +++ b/crates/common/src/telemetry/tracers/store.rs @@ -18,7 +18,7 @@ use registry::{ TraceValueIpAddr, TraceValueList, TraceValueString, TraceValueUTCDateTime, TraceValueUnsignedInt, }, - types::{datetime::UTCDateTime, ipaddr::IpAddr}, + types::{datetime::UTCDateTime, ipaddr::IpAddr, list::List}, }; use std::{collections::HashSet, future::Future, time::Duration}; use store::{ @@ -106,11 +106,13 @@ fn map_events<'x>( events.push(TraceEvent { event: event.inner.typ, timestamp: UTCDateTime::from_timestamp(event.inner.timestamp as i64), - key_values, + key_values: key_values.into(), }); } - Trace { events } + Trace { + events: events.into(), + } } fn map_value(value: &Value) -> TraceValue { @@ -149,7 +151,7 @@ fn map_value(value: &Value) -> TraceValue { event: event.event_type(), }), Value::Array(values) => TraceValue::List(TraceValueList { - value: values.iter().map(map_value).collect::>(), + value: List::from_iter(values.iter().map(map_value)), }), Value::None => TraceValue::Null, } diff --git a/crates/coordinator/src/backend/kafka/mod.rs b/crates/coordinator/src/backend/kafka/mod.rs index 306ee3d0..bc36e793 100644 --- a/crates/coordinator/src/backend/kafka/mod.rs +++ b/crates/coordinator/src/backend/kafka/mod.rs @@ -30,7 +30,7 @@ impl KafkaPubSub { return Err("No Kafka brokers specified".to_string()); } - let brokers = config.brokers.join(","); + let brokers = config.brokers.into_inner().join(","); let mut consumer_builder = ClientConfig::new(); consumer_builder diff --git a/crates/coordinator/src/backend/nats/mod.rs b/crates/coordinator/src/backend/nats/mod.rs index deffcc4b..0f18427f 100644 --- a/crates/coordinator/src/backend/nats/mod.rs +++ b/crates/coordinator/src/backend/nats/mod.rs @@ -49,7 +49,7 @@ impl NatsPubSub { .map_err(|err| format!("Failed to parse Nats credentials: {}", err))?; } - async_nats::connect_with_options(config.addresses, opts) + async_nats::connect_with_options(config.addresses.into_inner(), opts) .await .map(|client| Coordinator::Nats(Arc::new(NatsPubSub { client }))) .map_err(|err| format!("Failed to connect to Nats: {}", err)) diff --git a/crates/directory/src/backend/ldap/config.rs b/crates/directory/src/backend/ldap/config.rs index 8e8700b5..e6c54341 100644 --- a/crates/directory/src/backend/ldap/config.rs +++ b/crates/directory/src/backend/ldap/config.rs @@ -42,13 +42,13 @@ impl LdapDirectory { base_dn: config.base_dn, filter_login: LdapFilter::new(&config.filter_login)?, filter_mailbox: LdapFilter::new(&config.filter_mailbox)?, - attr_class: config.attr_class, - attr_groups: config.attr_groups, - attr_description: config.attr_description, - attr_secret: config.attr_secret, - attr_secret_changed: config.attr_secret_changed, - attr_email: config.attr_email, - attr_email_alias: config.attr_email_alias, + attr_class: config.attr_class.into_inner(), + attr_groups: config.attr_groups.into_inner(), + attr_description: config.attr_description.into_inner(), + attr_secret: config.attr_secret.into_inner(), + attr_secret_changed: config.attr_secret_changed.into_inner(), + attr_email: config.attr_email.into_inner(), + attr_email_alias: config.attr_email_alias.into_inner(), group_class: config.group_class, attrs_principal: vec![], }; diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs index 0c5ba690..142dca81 100644 --- a/crates/directory/src/backend/oidc/config.rs +++ b/crates/directory/src/backend/oidc/config.rs @@ -34,7 +34,7 @@ impl OpenIdDirectory { claim_email: config.claim_email, claim_name: config.claim_name, require_aud: config.require_audience, - require_scopes: config.require_scopes, + require_scopes: config.require_scopes.into_inner(), } } structs::OidcDirectory::Jwt(config) => OpenIdDirectory::Jwt { diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index e7326b9f..a64ebeb2 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -61,6 +61,29 @@ pub async fn verify_mfa_secret_hash( } } +pub fn verify_otp_auth(otp_auth: Option<&str>, otp_code: Option<&str>) -> trc::Result { + if let Some(otp_auth) = otp_auth { + if let Some(otp_code) = otp_code { + TOTP::from_url(otp_auth) + .map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details(otp_auth.to_string()) + })? + .check_current(otp_code) + .map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("TOTP verification failed") + }) + } else { + Ok(false) + } + } else { + Ok(true) + } +} + async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { if hashed_secret.starts_with("$argon2") || hashed_secret.starts_with("$pbkdf2") diff --git a/crates/email/src/message/copy.rs b/crates/email/src/message/copy.rs index cf98ad75..af8a5253 100644 --- a/crates/email/src/message/copy.rs +++ b/crates/email/src/message/copy.rs @@ -20,9 +20,12 @@ use crate::{ }; use common::{Server, storage::index::ObjectIndexBuilder}; use mail_parser::parsers::fields::thread::thread_name; -use registry::schema::{ - enums::IndexDocumentType, - structs::{Task, TaskIndexDocument, TaskMergeThreads, TaskStatus}, +use registry::{ + schema::{ + enums::IndexDocumentType, + structs::{Task, TaskIndexDocument, TaskMergeThreads, TaskStatus}, + }, + types::map::Map, }; use store::write::{BatchBuilder, IndexPropertyClass, ValueClass}; use store::{ @@ -218,11 +221,13 @@ impl EmailCopy for Server { account_id: to_account_id.into(), document_id: document_id.into(), status: TaskStatus::now(), - thread_ids: thread_result - .merge_ids - .into_iter() - .map(|id| id.into()) - .collect(), + thread_ids: Map::new( + thread_result + .merge_ids + .into_iter() + .map(|id| id.into()) + .collect(), + ), thread_hash: thread_result.thread_hash.to_string(), })); } diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index 5d953b64..0053fe95 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.rs @@ -30,7 +30,7 @@ use registry::{ prelude::{ObjectType, Permission, Property}, structs::{SpamTrainingSample, Task, TaskIndexDocument, TaskMergeThreads, TaskStatus}, }, - types::{EnumImpl, datetime::UTCDateTime, id::ObjectId}, + types::{EnumImpl, datetime::UTCDateTime, id::ObjectId, map::Map}, }; use std::future::Future; use std::{borrow::Cow, cmp::Ordering, fmt::Write, time::Instant}; @@ -676,11 +676,13 @@ impl EmailIngest for Server { account_id: account_id.into(), document_id: document_id.into(), status: TaskStatus::now(), - thread_ids: thread_result - .merge_ids - .into_iter() - .map(|id| id.into()) - .collect(), + thread_ids: Map::new( + thread_result + .merge_ids + .into_iter() + .map(|id| id.into()) + .collect(), + ), thread_hash: thread_result.thread_hash.to_string(), })); } diff --git a/crates/groupware/src/scheduling/itip.rs b/crates/groupware/src/scheduling/itip.rs index 9f0dfce9..5c2626cb 100644 --- a/crates/groupware/src/scheduling/itip.rs +++ b/crates/groupware/src/scheduling/itip.rs @@ -279,7 +279,7 @@ impl ItipMessages { i_calendar_data: m.message.to_string(), is_from_organizer: m.from_organizer, summary: serde_json::to_string(&m.summary).unwrap_or_default(), - to: m.to, + to: m.to.into(), }) .collect(), } @@ -289,7 +289,7 @@ impl ItipMessages { batch.schedule_task(Task::CalendarItipMessage(TaskCalendarItipMessage { account_id: batch.last_account_id().unwrap().into(), document_id: batch.last_document_id().unwrap().into(), - messages: self.messages, + messages: self.messages.into(), status: TaskStatus::now(), })); diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs index 5970ac0a..c2d429fa 100644 --- a/crates/http/src/auth/authenticate.rs +++ b/crates/http/src/auth/authenticate.rs @@ -35,6 +35,7 @@ impl Authenticator for Server { let access_token = AccessToken::renew( self.access_token(http_cache.account_id).await?, http_cache.credential_id, + session.remote_ip, )?; if access_token.revision() == http_cache.revision { diff --git a/crates/http/src/auth/oauth/registration.rs b/crates/http/src/auth/oauth/registration.rs index 5debdbe2..8c76a53e 100644 --- a/crates/http/src/auth/oauth/registration.rs +++ b/crates/http/src/auth/oauth/registration.rs @@ -86,9 +86,9 @@ impl ClientRegistrationHandler for Server { client_id: client_id.clone(), created_at: UTCDateTime::now(), description: request.client_name.clone(), - contacts: request.contacts.clone(), + contacts: request.contacts.clone().into(), member_tenant_id: tenant_id.map(|id| Id::new(id as u64)), - redirect_uris: request.redirect_uris.clone(), + redirect_uris: request.redirect_uris.clone().into(), logo: request.logo_uri.clone(), ..Default::default() } diff --git a/crates/http/src/autoconfig/mod.rs b/crates/http/src/autoconfig/mod.rs index 7969b488..cb804967 100644 --- a/crates/http/src/autoconfig/mod.rs +++ b/crates/http/src/autoconfig/mod.rs @@ -54,7 +54,7 @@ impl Autoconfig for Server { ); for listener in listeners { let listener = listener.object; - let Some(port) = listener.bind.first().map(|l| l.0.port()) else { + let Some(port) = listener.bind.as_slice().first().map(|l| l.0.port()) else { continue; }; let (protocol, tag) = match listener.protocol { @@ -161,7 +161,7 @@ impl Autoconfig for Server { let _ = writeln!(&mut config, "\t\t\tsettings"); for listener in listeners { let listener = listener.object; - let Some(port) = listener.bind.first().map(|l| l.0.port()) else { + let Some(port) = listener.bind.as_slice().first().map(|l| l.0.port()) else { continue; }; diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs index 62c63b3c..0096b0a8 100644 --- a/crates/imap/src/core/mailbox.rs +++ b/crates/imap/src/core/mailbox.rs @@ -39,6 +39,7 @@ impl SessionData { session_id: session.session_id, mailboxes: Mutex::new(vec![]), state: access_token.state().into(), + remote_addr: session.remote_addr, access_token, in_flight, }; diff --git a/crates/imap/src/core/mod.rs b/crates/imap/src/core/mod.rs index d49571b8..077aa501 100644 --- a/crates/imap/src/core/mod.rs +++ b/crates/imap/src/core/mod.rs @@ -67,6 +67,7 @@ pub struct SessionData { pub mailboxes: parking_lot::Mutex>, pub stream_tx: Arc>>, pub state: AtomicU32, + pub remote_addr: IpAddr, pub in_flight: Option, } @@ -194,7 +195,9 @@ impl SessionData { self.server .access_token(self.account_id) .await - .and_then(|inner| AccessToken::renew(inner, self.access_token.credential_id())) + .and_then(|inner| { + AccessToken::renew(inner, self.access_token.credential_id(), self.remote_addr) + }) .caused_by(trc::location!()) } @@ -211,6 +214,7 @@ impl SessionData { state: self.state, in_flight: self.in_flight, access_token: self.access_token, + remote_addr: self.remote_addr, } } } diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index 7400e4dc..3eeaf8c8 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -375,7 +375,8 @@ impl Session { // Invalidate ACLs data.server .invalidate_caches(CacheInvalidation::AccessToken(acl_account_id).into()) - .await; + .await + .imap_ctx(&arguments.tag, trc::location!())?; trc::event!( Imap(trc::ImapEvent::SetAcl), diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs index c977f717..2be5b7b1 100644 --- a/crates/jmap/src/registry/mapping/account.rs +++ b/crates/jmap/src/registry/mapping/account.rs @@ -4,20 +4,361 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::registry::mapping::RegistryGetResponse; -use registry::{ - jmap::IntoValue, - schema::{ - prelude::ObjectType, - structs::{Account, AccountSettings}, - }, +use crate::registry::{ + mapping::{RegistryGetResponse, RegistrySetResponse}, + set::map_write_error, }; +use directory::core::secret::{hash_secret, verify_otp_auth, verify_secret_hash}; +use jmap_proto::error::set::SetError; +use jmap_tools::{JsonPointer, JsonPointerItem, Key}; +use registry::{ + jmap::{IntoValue, JsonPointerPatch, MaybeUnpatched, RegistryJsonPatch}, + schema::{ + prelude::{MASKED_PASSWORD, Object, ObjectInner, ObjectType, Property}, + structs::{Account, AccountSettings, Credential}, + }, + types::id::ObjectId, +}; +use store::registry::write::{RegistryWrite, RegistryWriteResult}; +use trc::AddContext; use types::id::Id; +use utils::map::vec_map::VecMap; + +pub(crate) async fn account_set( + mut set: RegistrySetResponse<'_>, +) -> trc::Result> { + let item_id = Id::from(set.account_id); + let Some(object) = set + .server + .registry() + .get(ObjectId::new(ObjectType::Account, item_id)) + .await? + else { + set.fail_all(SetError::not_found()); + return Ok(set); + }; + let revision = object.revision; + let old_account = if let ObjectInner::Account(Account::User(account)) = object.inner { + account + } else { + set.fail_all(SetError::not_found()); + return Ok(set); + }; + let mut account = old_account.clone(); + + match set.object_type { + ObjectType::AccountSettings => { + 'outer: for (id, value) in set.update.drain(..) { + if id != Id::singleton() { + set.response.not_updated.append(id, SetError::not_found()); + } + + for (key, value) in value.into_expanded_object() { + if let Key::Property( + property @ (Property::EncryptionAtRest + | Property::Locale + | Property::Description), + ) = key + { + let ptr = + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(property))]); + if let Err(err) = + account.patch(JsonPointerPatch::new(&ptr).with_create(true), value) + { + set.response.not_updated.append(id, err.into()); + break 'outer; + } + } else { + set.response.not_updated.append( + id, + SetError::invalid_properties().with_property(key.into_owned()), + ); + break 'outer; + } + } + } + + if account.encryption_at_rest != old_account.encryption_at_rest { + let todo = "validate pk"; + } + } + ObjectType::Credential => { + 'outer: for (id, value) in set.update.drain(..) { + if let Some(credential) = account + .credentials + .values_mut() + .find(|credential| credential.credential_id() == id) + { + let old_credential = credential.clone(); + let mut unpatched_properties = VecMap::new(); + + for (key, value) in value.into_expanded_object() { + let ptr = match key { + Key::Property(prop) => { + JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]) + } + Key::Borrowed(other) => JsonPointer::parse(other), + Key::Owned(other) => JsonPointer::parse(&other), + }; + + match credential + .patch(JsonPointerPatch::new(&ptr).with_create(false), 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.response.not_updated.append(id, err.into()); + continue 'outer; + } + } + } + + if credential == &old_credential { + set.response.updated.append(id, None); + continue 'outer; + } + + match (credential, old_credential) { + ( + Credential::Password(credential), + Credential::Password(old_credential), + ) => { + // Reset the original password if the client accidentally sent the masked password + if credential.secret.is_empty() || credential.secret == MASKED_PASSWORD + { + credential.secret = old_credential.secret.clone(); + } + if credential + .otp_auth + .as_ref() + .is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD) + { + credential.otp_auth = old_credential.otp_auth.clone(); + } + + if (credential.secret != old_credential.secret + || credential.otp_auth != old_credential.otp_auth) + && 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() + { + set.response.not_updated.append( + id, + SetError::forbidden() + .with_description("Operation not allowed."), + ); + continue 'outer; + } + + if credential.secret != old_credential.secret + || credential.otp_auth != old_credential.otp_auth + { + if old_credential.secret.is_empty() { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Cannot set a password or OTP auth on an account that doesn't have one.", + ), + ); + continue 'outer; + } + + let current_otp_code = unpatched_properties + .get(&Property::OtpCode) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()); + if let Some(current_secret) = unpatched_properties + .get(&Property::CurrentSecret) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + { + if !verify_secret_hash( + &old_credential.secret, + current_secret.as_bytes(), + ) + .await? + || !verify_otp_auth( + old_credential.otp_auth.as_deref(), + current_otp_code.as_deref(), + )? + { + let account = set.server.account(set.account_id).await?; + if set.server.has_auth_fail2ban() + && set + .server + .is_auth_fail2banned( + set.remote_ip, + account.name().into(), + ) + .await? + { + return Err(trc::SecurityEvent::AuthenticationBan + .into_err() + .details( + "Too many failed password change attempts.", + ) + .ctx(trc::Key::RemoteIp, set.remote_ip) + .ctx( + trc::Key::AccountName, + account.name().to_string(), + )); + } else { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Current secret is incorrect.", + ), + ); + continue 'outer; + } + } + + if credential.secret != old_credential.secret { + credential.secret = hash_secret( + set.server + .core + .network + .security + .password_hash_algorithm, + std::mem::take(&mut credential.secret), + ) + .await + .caused_by(trc::location!())?; + } + + if credential.otp_auth != old_credential.otp_auth + && !verify_otp_auth( + credential.otp_auth.as_deref(), + current_otp_code.as_deref(), + )? + { + set.response.not_updated.append( + id, + SetError::forbidden() + .with_description("OTP URL or token is invalid."), + ); + continue 'outer; + } + } else { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Current secret must be provided to change the password or OTP auth.", + ), + ); + continue 'outer; + } + } + } + ( + Credential::AppPassword(credential), + Credential::AppPassword(old_credential), + ) + | (Credential::ApiKey(credential), Credential::ApiKey(old_credential)) => { + // Paranoid check, this is verified in the patch implementation + if credential.secret != old_credential.secret { + set.response.not_updated.append( + id, + SetError::forbidden().with_description( + "Cannot change the value of an app password or API key.", + ), + ); + continue 'outer; + } + } + _ => {} + } + + set.response.updated.append(id, None); + } else { + set.response.not_updated.append(id, SetError::not_found()); + } + } + + for id in set.destroy.drain(..) { + if let Some(idx) = account.credentials.0.inner.iter_mut().position(|c| { + c.value.credential_id() == id && !matches!(c.value, Credential::Password(_)) + }) { + account.credentials.inner_mut().inner.remove(idx); + set.response.destroyed.push(id); + } else { + set.response.not_destroyed.append(id, SetError::not_found()); + } + } + } + _ => unreachable!(), + } + + if account != old_account { + let object = Object::new(ObjectInner::Account(Account::User(account))); + let old_object = Object::with_revision( + ObjectInner::Account(Account::User(old_account.clone())), + revision, + ); + + match set + .server + .registry() + .write(RegistryWrite::Update { + object: &object, + id: item_id, + old_object: &old_object, + }) + .await? + { + RegistryWriteResult::Success(_) => {} + err => { + let err = map_write_error(err); + let failed_create = set + .response + .created + .into_keys() + .map(|id| (id, err.clone())) + .collect::>(); + let failed_update = set + .response + .updated + .into_keys() + .map(|id| (id, err.clone())) + .collect::>(); + let failed_delete = set + .response + .destroyed + .into_iter() + .map(|id| (id, err.clone())) + .collect::>(); + + set.response.not_created.extend(failed_create); + set.response.not_updated.extend(failed_update); + set.response.not_destroyed.extend(failed_delete); + set.response.created = Default::default(); + set.response.updated = Default::default(); + set.response.destroyed = Default::default(); + } + } + } + + Ok(set) +} pub(crate) async fn account_get( mut get: RegistryGetResponse<'_>, ) -> trc::Result> { - let Some(Account::User(mut account)) = get + let Some(Account::User(account)) = get .server .registry() .object::(get.account_id.into()) @@ -25,13 +366,6 @@ pub(crate) async fn account_get( else { return Ok(get.not_found_any()); }; - if get.access_token.tenant_id().is_some_and(|id| { - account - .member_tenant_id - .is_none_or(|aid| aid.document_id() != id) - }) { - return Ok(get.not_found_any()); - } match get.object_type { ObjectType::AccountSettings => { @@ -48,8 +382,7 @@ pub(crate) async fn account_get( AccountSettings { encryption_at_rest: account.encryption_at_rest, locale: account.locale, - otp_auth: account.otp_auth, - secret: account.secret, + description: account.description, } .into_value(), ); @@ -62,23 +395,27 @@ pub(crate) async fn account_get( get.response.not_found.extend(ids); } ObjectType::Credential => { - let ids = if let Some(ids) = get.ids.take() { + let mut ids = if let Some(ids) = get.ids.take() { ids } else { account .credentials - .keys() - .map(|id| Id::from(*id)) + .values() + .map(|credential| credential.credential_id()) .collect::>() }; - for id in ids { - if let Some(credential) = account.credentials.remove(&id.document_id()) { + for credential in account.credentials { + let id = credential.credential_id(); + if ids.contains(&id) { get.insert(id, credential.into_value()); - } else { - get.not_found(id); + ids.retain(|i| i != &id); } } + + for id in ids { + get.not_found(id); + } } _ => unreachable!(), } diff --git a/crates/jmap/src/registry/mapping/mod.rs b/crates/jmap/src/registry/mapping/mod.rs index 25d6141c..c54a2595 100644 --- a/crates/jmap/src/registry/mapping/mod.rs +++ b/crates/jmap/src/registry/mapping/mod.rs @@ -4,6 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use std::net::IpAddr; + use common::{Server, auth::AccessToken}; use jmap_proto::{ error::set::SetError, @@ -46,6 +48,7 @@ pub(crate) struct RegistryGetResponse<'x> { pub(crate) struct RegistrySetResponse<'x> { pub server: &'x Server, + pub remote_ip: IpAddr, pub access_token: &'x AccessToken, pub account_id: u32, pub create: VecMap>, diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs index 72a4d0b9..18470dd3 100644 --- a/crates/jmap/src/registry/mapping/principal.rs +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -5,20 +5,23 @@ */ use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult}; -use common::auth::PermissionsGroup; +use common::{ + Server, + auth::{Permissions, PermissionsGroup, permissions::BuildPermissions}, +}; 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}, + structs::{Account, Credential, Role}, }, types::EnumImpl, }; use store::registry::RegistryQuery; use trc::AddContext; +use types::id::Id; pub(crate) async fn validate_account( set: &RegistrySetResponse<'_>, @@ -54,43 +57,114 @@ pub(crate) async fn validate_account( 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(); - } + // Validate credentials + let has_password = account.credentials.values().any(|credential| { + matches!(credential, Credential::Password(credential) if credential.credential_id.is_valid()) + }); + let mut max_credential_id = 0; + let mut has_new_credentials = false; + for credential in account.credentials.values_mut() { + let credential_id = credential.credential_id(); - // 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 credential_id.is_valid() && credential_id.id() > max_credential_id { + max_credential_id = credential_id.id(); } - 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!())?; + + if let Some(old_credential) = old_account + .credentials + .values() + .find(|c| c.credential_id() == credential_id) + { + if credential != old_credential { + match (credential, old_credential) { + ( + Credential::Password(credential), + Credential::Password(old_credential), + ) => { + if is_external_directory { + return Ok(Err(SetError::forbidden().with_description( + "Cannot change credentials for accounts in an external directory.", + ))); + } + + // Reset the original password if the client accidentally sent the masked password + if credential.secret == MASKED_PASSWORD { + credential.secret = old_credential.secret.clone(); + } + if credential + .otp_auth + .as_ref() + .is_some_and(|otp_auth| otp_auth == MASKED_PASSWORD) + { + credential.otp_auth = old_credential.otp_auth.clone(); + } + + if credential.secret != old_credential.secret { + if !credential.secret.is_empty() { + credential.secret = hash_secret( + set.server + .core + .network + .security + .password_hash_algorithm, + std::mem::take(&mut credential.secret), + ) + .await + .caused_by(trc::location!())?; + } else { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Secret) + .with_description("Password cannot be empty."))); + } + } + } + ( + Credential::AppPassword(credential), + Credential::AppPassword(old_credential), + ) + | ( + Credential::ApiKey(credential), + Credential::ApiKey(old_credential), + ) => { + // Reset the original password if the client accidentally sent the masked password + if credential.secret == MASKED_PASSWORD { + credential.secret = old_credential.secret.clone(); + } + + if credential.secret != old_credential.secret { + return Ok(Err(SetError::forbidden().with_description( + "Cannot change app password or API credentials through this method.", + ))); + } + } + _ => { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Credentials) + .with_description("Credential type cannot be changed."))); + } + } + } + } else if let Err(err) = validate_credential_creation( + set.server, + credential, + is_external_directory, + has_password, + ) + .await? + { + return Ok(Err(err)); } else { - return Ok(Err(SetError::invalid_properties() - .with_property(Property::Secret) - .with_description("Password cannot be empty."))); + has_new_credentials = true; } } - 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.", - ))); + if has_new_credentials { + for credential in account.credentials.values_mut() { + if !credential.credential_id().is_valid() { + max_credential_id += 1; + credential.set_credential_id(Id::from(max_credential_id)); + } + } } account.permissions != old_account.permissions || account.roles != old_account.roles @@ -104,31 +178,19 @@ pub(crate) async fn validate_account( 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), + // Validate credentials + for (index, credential) in account.credentials.values_mut().enumerate() { + if let Err(err) = validate_credential_creation( + set.server, + credential, + is_external_directory, + index > 0, ) - .await - .caused_by(trc::location!())?; - } else { - return Ok(Err(SetError::invalid_properties() - .with_property(Property::Secret) - .with_description("Password cannot be empty."))); + .await? + { + return Ok(Err(err)); + } + credential.set_credential_id(Id::from(index as u64)); } true @@ -156,6 +218,48 @@ pub(crate) async fn validate_account( } } +async fn validate_credential_creation( + server: &Server, + credential: &mut Credential, + is_external_directory: bool, + has_password: bool, +) -> trc::Result>> { + match credential { + Credential::Password(credential) => { + if is_external_directory { + return Ok(Err(SetError::forbidden().with_description( + "Cannot set credentials for accounts in an external directory.", + ))); + } else if has_password { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Credentials) + .with_description("Only one password credential is allowed."))); + } + + if credential.secret.is_empty() { + credential.secret = hash_secret( + server.core.network.security.password_hash_algorithm, + std::mem::take(&mut credential.secret), + ) + .await + .caused_by(trc::location!())?; + Ok(Ok(())) + } else { + Ok(Err(SetError::invalid_properties() + .with_property(Property::Secret) + .with_description("Password cannot be empty."))) + } + } + Credential::AppPassword(_) | Credential::ApiKey(_) => { + Ok(Err(SetError::invalid_properties() + .with_property(Property::Credentials) + .with_description( + "Secondary credentials cannot be set directly.", + ))) + } + } +} + pub(crate) async fn validate_role( set: &RegistrySetResponse<'_>, role: &mut Role, @@ -169,11 +273,20 @@ pub(crate) async fn validate_role( } if old_role.is_none_or(|old_role| { - old_role.permissions != role.permissions || old_role.role_ids != role.role_ids + old_role.enabled_permissions != role.enabled_permissions + || old_role.disabled_permissions != role.disabled_permissions + || old_role.role_ids != role.role_ids }) { Ok(set .access_token - .can_grant_permissions(PermissionsGroup::from(&role.permissions).finalize()) + .can_grant_permissions( + PermissionsGroup { + enabled: Permissions::from_permission(role.enabled_permissions.as_slice()), + disabled: Permissions::from_permission(role.disabled_permissions.as_slice()), + merge: false, + } + .finalize(), + ) .map(|_| ObjectResponse::default()) .map_err(build_set_error)) } else { diff --git a/crates/jmap/src/registry/mapping/queued_message.rs b/crates/jmap/src/registry/mapping/queued_message.rs index f8aeea25..c18c6f18 100644 --- a/crates/jmap/src/registry/mapping/queued_message.rs +++ b/crates/jmap/src/registry/mapping/queued_message.rs @@ -15,7 +15,7 @@ use registry::{ QueuedRecipient, RecipientStatus, ServerResponse, }, }, - types::{datetime::UTCDateTime, ipaddr::IpAddr}, + types::{datetime::UTCDateTime, ipaddr::IpAddr, list::List, map::Map}, }; use smtp::queue::{spool::SmtpSpool, *}; use store::{ @@ -68,11 +68,11 @@ fn map_message(message_in: &ArchivedMessage) -> QueuedMessage { blob_id: BlobId::new(BlobHash::from(&message_in.blob_hash), Default::default()), created_at: UTCDateTime::from_timestamp(message_in.created.to_native() as i64), env_id: message_in.env_id.as_ref().map(|v| v.to_string()), - flags: Vec::with_capacity(1), + flags: Map::with_capacity(1), priority: message_in.priority.to_native() as i64, received_from_ip: IpAddr(message_in.received_from_ip.as_ipaddr()), received_via_port: message_in.received_via_port.to_native() as u64, - recipients: Vec::with_capacity(message_in.recipients.len()), + recipients: List::with_capacity(message_in.recipients.len()), return_path: message_in.return_path.to_string(), size: message_in.size.to_native(), }; @@ -109,7 +109,7 @@ fn map_message(message_in: &ArchivedMessage) -> QueuedMessage { }) } }, - flags: vec![], + flags: Default::default(), notify_count: rcpt_in.notify.inner.to_native() as u64, notify_due: UTCDateTime::from_timestamp(rcpt_in.notify.due.to_native() as i64), orcpt: rcpt_in.orcpt.as_ref().map(|v| v.to_string()), diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index c017201a..003e31fa 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -593,6 +593,18 @@ impl RegistrySetResponse<'_> { } } + pub fn fail_all(&mut self, error: SetError) { + for (client_id, _) in self.create.drain() { + self.response.not_created.append(client_id, error.clone()); + } + for (id, _) in self.update.drain(..) { + self.response.not_updated.append(id, error.clone()); + } + for id in self.destroy.drain(..) { + self.response.not_destroyed.append(id, error.clone()); + } + } + pub fn fail_all_create(&mut self, error: impl Into>) { let error = error.into(); for (client_id, _) in self.create.drain() { @@ -658,7 +670,7 @@ impl Modification { } } -fn map_write_error(err: RegistryWriteResult) -> SetError { +pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError { match err { RegistryWriteResult::CannotDeleteLinked { object_id, diff --git a/crates/registry/src/jmap/patch.rs b/crates/registry/src/jmap/patch.rs index 406ad3ae..b6a08920 100644 --- a/crates/registry/src/jmap/patch.rs +++ b/crates/registry/src/jmap/patch.rs @@ -13,11 +13,11 @@ use crate::{ types::{ EnumImpl, error::PatchError, + map::MapItem, string::{StringValidator, StringValidatorResult}, }, }; use jmap_tools::{JsonPointer, JsonPointerItem, Key, Value}; -use std::fmt::Debug; use utils::map::vec_map::VecMap; impl<'x> JsonPointerPatch<'x> { @@ -94,6 +94,13 @@ impl<'x> JsonPointerPatch<'x> { )) } } + + pub fn assert_server_set(self) -> PatchResult<'static> { + Err(PatchError::new( + self.cloned(), + "Cannot modify server-set property", + )) + } } impl RegistryJsonPatch for Option { @@ -249,80 +256,6 @@ impl RegistryJsonEnumPatch for T { } } -impl RegistryJsonPatch for Vec { - fn patch<'x>( - &mut self, - mut pointer: JsonPointerPatch<'_>, - 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(MaybeUnpatched::Patched); - } - } - (Some(JsonPointerItem::Number(idx)), value) => { - if let Some(inner) = self.get_mut(*idx as usize) { - return inner.patch(pointer, value); - } else if *idx == self.len() as u64 { - let mut inner = T::default(); - return inner.patch(pointer, value).inspect(|_| self.push(inner)); - } - } - (None, Value::Array(items)) => { - self.clear(); - for item in items { - let mut inner = T::default(); - inner.patch(pointer.clone(), item)?; - self.push(inner); - } - return Ok(MaybeUnpatched::Patched); - } - _ => {} - } - - Err(PatchError::new(pointer, "Invalid value for array property")) - } -} - -impl RegistryJsonEnumPatch for Vec { - fn patch<'x>( - &mut self, - mut pointer: JsonPointerPatch<'_>, - 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(MaybeUnpatched::Patched); - } - } - (Some(JsonPointerItem::Number(idx)), value) => { - if let Some(inner) = self.get_mut(*idx as usize) { - return inner.patch(pointer, value); - } else if *idx == self.len() as u64 { - let mut inner = T::default(); - return inner.patch(pointer, value).inspect(|_| self.push(inner)); - } - } - (None, Value::Array(items)) => { - self.clear(); - for item in items { - let mut inner = T::default(); - inner.patch(pointer.clone(), item)?; - self.push(inner); - } - return Ok(MaybeUnpatched::Patched); - } - _ => {} - } - - Err(PatchError::new(pointer, "Invalid value for array property")) - } -} - impl RegistryJsonPatch for VecMap { fn patch<'x>( &mut self, @@ -426,46 +359,6 @@ impl RegistryJsonPatch for T { } } -trait MapItem: Sized + PartialEq + Eq + Debug { - fn try_from_string(value: &str) -> Option; - fn try_from_integer(value: u64) -> Option; -} - -impl MapItem for String { - fn try_from_string(value: &str) -> Option { - let value = value.trim(); - if !value.is_empty() { - Some(value.to_string()) - } else { - None - } - } - - fn try_from_integer(value: u64) -> Option { - Some(value.to_string()) - } -} - -impl MapItem for u32 { - fn try_from_string(value: &str) -> Option { - value.parse().ok() - } - - fn try_from_integer(value: u64) -> Option { - value.try_into().ok() - } -} - -impl MapItem for T { - fn try_from_string(value: &str) -> Option { - Self::parse(value) - } - - fn try_from_integer(_: u64) -> Option { - None - } -} - pub fn object_type( pointer: &JsonPointerPatch<'_>, value: &Value<'_, Property, RegistryValue>, diff --git a/crates/registry/src/jmap/ser.rs b/crates/registry/src/jmap/ser.rs index df048c17..bef3bb10 100644 --- a/crates/registry/src/jmap/ser.rs +++ b/crates/registry/src/jmap/ser.rs @@ -84,17 +84,6 @@ impl IntoValue for VecMap { } } -impl IntoValue for Vec { - fn into_value(self) -> JmapValue<'static> { - let mut array = Vec::with_capacity(self.len()); - for v in self { - array.push(v.into_value()); - } - - JmapValue::Array(array) - } -} - impl IntoValue for trc::Key { fn into_value(self) -> JmapValue<'static> { JmapValue::Str(self.name().into()) diff --git a/crates/registry/src/pickle.rs b/crates/registry/src/pickle.rs index 43351499..ce5c8a70 100644 --- a/crates/registry/src/pickle.rs +++ b/crates/registry/src/pickle.rs @@ -167,27 +167,6 @@ where } } -impl Pickle for Vec -where - T: Pickle, -{ - fn pickle(&self, out: &mut Vec) { - (self.len() as u32).pickle(out); - for item in self { - item.pickle(out); - } - } - - fn unpickle(stream: &mut PickledStream<'_>) -> Option { - let len = u32::unpickle(stream)? as usize; - let mut vec = Vec::with_capacity(len); - for _ in 0..len { - vec.push(T::unpickle(stream)?); - } - Some(vec) - } -} - impl Pickle for HashMap where K: Pickle + std::hash::Hash + Eq, diff --git a/crates/registry/src/schema/mod.rs b/crates/registry/src/schema/mod.rs index fd649e09..64c6d24c 100644 --- a/crates/registry/src/schema/mod.rs +++ b/crates/registry/src/schema/mod.rs @@ -7,7 +7,7 @@ use crate::{ schema::{ enums::{TracingLevel, TracingLevelOpt}, - prelude::{Credential, CredentialProperties, NodeRange, Object, ObjectInner, Property}, + prelude::{NodeRange, Object, ObjectInner, Property}, }, types::EnumImpl, }; @@ -34,22 +34,6 @@ impl NodeRange { } } -impl Credential { - pub fn unwrap_properties(self) -> CredentialProperties { - match self { - Credential::AppPassword(credential_properties) => credential_properties, - Credential::ApiKey(credential_properties) => credential_properties, - } - } - - pub fn as_properties(&self) -> &CredentialProperties { - match self { - Credential::AppPassword(credential_properties) => credential_properties, - Credential::ApiKey(credential_properties) => credential_properties, - } - } -} - impl Display for Property { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) @@ -146,4 +130,8 @@ impl Object { pub fn new(inner: ObjectInner) -> Self { Object { inner, revision: 0 } } + + pub fn with_revision(inner: ObjectInner, revision: u64) -> Self { + Object { inner, revision } + } } diff --git a/crates/registry/src/schema/prelude.rs b/crates/registry/src/schema/prelude.rs index 0f23c76f..67dc58f9 100644 --- a/crates/registry/src/schema/prelude.rs +++ b/crates/registry/src/schema/prelude.rs @@ -7,9 +7,9 @@ pub use crate::jmap::IntoValue; pub use crate::jmap::JmapValue; pub use crate::jmap::MaybeUnpatched; pub use crate::jmap::PatchResult; +pub use crate::jmap::RegistryJsonEnumPatch; pub use crate::jmap::{ - JsonPointerPatch, RegistryJsonEnumPatch, RegistryJsonPatch, RegistryJsonPropertyPatch, - patch::object_type, + JsonPointerPatch, RegistryJsonPatch, RegistryJsonPropertyPatch, patch::object_type, }; pub use crate::pickle::Pickle; pub use crate::schema::enums::*; @@ -24,6 +24,8 @@ pub use crate::types::float::Float; pub use crate::types::index::IndexBuilder; pub use crate::types::ipaddr::IpAddr; pub use crate::types::ipmask::IpAddrOrMask; +pub use crate::types::list::List; +pub use crate::types::map::Map; pub use crate::types::socketaddr::SocketAddr; pub use crate::types::string::StringValidator; pub use serde::{Deserialize, Serialize}; diff --git a/crates/registry/src/types/ipmask.rs b/crates/registry/src/types/ipmask.rs index d932f666..7f3ca814 100644 --- a/crates/registry/src/types/ipmask.rs +++ b/crates/registry/src/types/ipmask.rs @@ -17,7 +17,7 @@ use std::{ str::FromStr, }; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum IpAddrOrMask { V4 { addr: Ipv4Addr, mask: u32 }, V6 { addr: Ipv6Addr, mask: u128 }, diff --git a/crates/registry/src/types/list.rs b/crates/registry/src/types/list.rs new file mode 100644 index 00000000..dc4bcdd6 --- /dev/null +++ b/crates/registry/src/types/list.rs @@ -0,0 +1,237 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + types::error::PatchError, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{self, MapAccess, Visitor}, + ser::SerializeMap, +}; +use std::{ + fmt::{self, Debug}, + marker::PhantomData, +}; +use utils::map::vec_map::{KeyValue, VecMap}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct List(pub VecMap); + +impl List { + pub fn with_capacity(capacity: usize) -> Self { + Self(VecMap::with_capacity(capacity)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.0.values() + } + + pub fn values(&self) -> impl Iterator { + self.0.values() + } + + pub fn values_mut(&mut self) -> impl Iterator { + self.0.values_mut() + } + + pub fn push(&mut self, item: T) { + let next_index = self.0.last().map(|(k, _)| *k + 1).unwrap_or(0); + self.0.append(next_index, item); + } + + pub fn push_unchecked(&mut self, item: T) { + let next_index = self.0.len() as u32; + self.0.append(next_index, item); + } + + pub fn inner_mut(&mut self) -> &mut VecMap { + &mut self.0 + } +} + +impl Pickle for List +where + T: Pickle, +{ + fn pickle(&self, out: &mut Vec) { + (self.0.len() as u32).pickle(out); + for item in self.0.values() { + item.pickle(out); + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + let len = u32::unpickle(stream)? as usize; + let mut vec = Self::with_capacity(len); + for _ in 0..len { + vec.push_unchecked(T::unpickle(stream)?); + } + Some(vec) + } +} + +impl RegistryJsonPatch for List { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (pointer.next(), value) { + (Some(JsonPointerItem::Number(key)), Value::Null) => { + if self.0.remove(&(*key as u32)).is_some() { + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Key(key)), Value::Null) => { + if let Ok(key) = key.to_string().parse::() + && self.0.remove(&key).is_some() + { + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Key(key)), value) => { + if let Ok(key) = key.to_string().parse::() { + let result = self.0.get_mut_or_insert(key).patch(pointer, value); + + self.0.sort_unstable_by_key(); + + return result; + } + } + (Some(JsonPointerItem::Number(key)), value) => { + let result = self.0.get_mut_or_insert(*key as u32).patch(pointer, value); + + self.0.sort_unstable_by_key(); + + return result; + } + (None, Value::Object(items)) => { + self.0.clear(); + for (key, value) in items.into_vec() { + if let Ok(key) = key.to_string().parse::() { + let mut inner = T::default(); + inner.patch(pointer.clone(), value)?; + self.0.set(key, inner); + } else { + return Err(PatchError::new( + pointer.clone(), + "Invalid key for object property", + )); + } + } + self.0.sort_unstable_by_key(); + return Ok(MaybeUnpatched::Patched); + } + _ => {} + } + + Err(PatchError::new( + pointer, + "Invalid value for object property", + )) + } +} + +impl IntoValue for List { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(self.0.len()); + for (idx, v) in self.0 { + map.insert_unchecked(Key::Owned(idx.to_string()), v.into_value()); + } + + JmapValue::Object(map) + } +} + +impl Serialize for List { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (key, value) in &self.0 { + map.serialize_entry(&key.to_string(), value)?; + } + map.end() + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for List { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ListVisitor(PhantomData); + + impl<'de, T: Deserialize<'de>> Visitor<'de> for ListVisitor { + type Value = List; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of string keys to values") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut items = VecMap::with_capacity(map.size_hint().unwrap_or(0)); + + while let Some(key) = map.next_key::<&str>()? { + let id: u32 = key + .parse() + .map_err(|_| de::Error::custom(format!("invalid integer key: {key}")))?; + let value: T = map.next_value()?; + items.set(id, value); + } + + items.sort_unstable_by_key(); + + Ok(List(items)) + } + } + + deserializer.deserialize_map(ListVisitor(PhantomData)) + } +} + +impl FromIterator for List { + fn from_iter>(iter: I) -> Self { + Self(VecMap::from_iter( + iter.into_iter().enumerate().map(|(i, v)| (i as u32, v)), + )) + } +} + +impl From> for List { + fn from(vec: Vec) -> Self { + Self(VecMap::from_iter( + vec.into_iter().enumerate().map(|(i, v)| (i as u32, v)), + )) + } +} + +impl IntoIterator for List { + type Item = T; + type IntoIter = std::iter::Map>, fn(KeyValue) -> T>; + + fn into_iter(self) -> Self::IntoIter { + self.0.inner.into_iter().map(|kv| kv.value) + } +} diff --git a/crates/registry/src/types/map.rs b/crates/registry/src/types/map.rs new file mode 100644 index 00000000..1bb8de4a --- /dev/null +++ b/crates/registry/src/types/map.rs @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ + IntoValue, JmapValue, JsonPointerPatch, MaybeUnpatched, PatchResult, RegistryJsonPatch, + }, + pickle::{Pickle, PickledStream}, + schema::prelude::SocketAddr, + types::{EnumImpl, error::PatchError, ipaddr::IpAddr, ipmask::IpAddrOrMask}, +}; +use jmap_tools::{JsonPointerItem, Key, Value}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{self, MapAccess, Visitor}, + ser::SerializeMap, +}; +use std::{ + borrow::Cow, + fmt::{self, Debug}, + marker::PhantomData, + str::FromStr, +}; +use types::id::Id; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Map(Vec); + +impl Map { + pub fn new(items: Vec) -> Self { + Self(items) + } + + pub fn with_capacity(capacity: usize) -> Self { + Self(Vec::with_capacity(capacity)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn into_inner(self) -> Vec { + self.0 + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + pub fn as_slice(&self) -> &[T] { + &self.0 + } + + pub fn push(&mut self, item: T) { + if !self.0.contains(&item) { + self.0.push(item); + } + } + + pub fn push_unchecked(&mut self, item: T) { + self.0.push(item); + } +} + +impl Pickle for Map +where + T: Pickle + MapItem, +{ + fn pickle(&self, out: &mut Vec) { + (self.0.len() as u32).pickle(out); + for item in &self.0 { + item.pickle(out); + } + } + + fn unpickle(stream: &mut PickledStream<'_>) -> Option { + let len = u32::unpickle(stream)? as usize; + let mut vec = Vec::with_capacity(len); + for _ in 0..len { + vec.push(T::unpickle(stream)?); + } + Some(Self(vec)) + } +} + +impl IntoValue for Map { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(self.0.len()); + for v in self.0 { + let key = match v.into_string() { + Cow::Borrowed(s) => Key::Borrowed(s), + Cow::Owned(s) => Key::Owned(s), + }; + map.insert_unchecked(key, Value::Bool(true)); + } + + JmapValue::Object(map) + } +} + +impl RegistryJsonPatch for Map { + fn patch<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match (pointer.next(), value) { + (Some(JsonPointerItem::Number(idx)), Value::Null | Value::Bool(false)) => { + if let Some(key) = T::try_from_integer(*idx) { + self.0.retain(|item| item != &key); + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Key(key)), Value::Null | Value::Bool(false)) => { + if let Some(key) = T::try_from_string(key.to_string().as_ref()) { + self.0.retain(|item| item != &key); + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Key(key)), Value::Bool(true)) => { + if let Some(key) = T::try_from_string(key.to_string().as_ref()) { + if !self.0.contains(&key) { + self.0.push(key); + } + + return Ok(MaybeUnpatched::Patched); + } + } + (Some(JsonPointerItem::Number(idx)), Value::Bool(true)) => { + if let Some(key) = T::try_from_integer(*idx) { + if !self.0.contains(&key) { + self.0.push(key); + } + + return Ok(MaybeUnpatched::Patched); + } + } + (None, Value::Object(items)) => { + self.0.clear(); + for (key, value) in items.into_vec() { + if let (Some(key), Value::Bool(is_set)) = + (T::try_from_string(key.to_string().as_ref()), value) + { + if is_set && !self.0.contains(&key) { + self.0.push(key); + } + } else { + return Err(PatchError::new( + pointer.clone(), + "Invalid key for object property", + )); + } + } + return Ok(MaybeUnpatched::Patched); + } + _ => {} + } + + Err(PatchError::new( + pointer, + "Invalid value for object property", + )) + } +} + +impl Serialize for Map { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for item in &self.0 { + map.serialize_entry(&item.as_string() as &str, &true)?; + } + map.end() + } +} + +impl<'de, T: MapItem> Deserialize<'de> for Map { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct MapVisitor(PhantomData); + + impl<'de, T: MapItem> Visitor<'de> for MapVisitor { + type Value = Map; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of string keys to booleans or nulls") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut items = Vec::with_capacity(map.size_hint().unwrap_or(0)); + + while let Some(key) = map.next_key::<&str>()? { + let value: Option = map.next_value()?; + + if value == Some(true) { + let item = T::try_from_string(key) + .ok_or_else(|| de::Error::custom(format!("invalid map key: {key}")))?; + if !items.contains(&item) { + items.push(item); + } + } + } + + Ok(Map(items)) + } + } + + deserializer.deserialize_map(MapVisitor(PhantomData)) + } +} + +pub trait MapItem: Sized + PartialEq + Eq + Debug { + fn try_from_string(value: &str) -> Option; + fn try_from_integer(value: u64) -> Option; + fn into_string(self) -> Cow<'static, str>; + fn as_string(&self) -> Cow<'_, str>; +} + +impl MapItem for String { + fn try_from_string(value: &str) -> Option { + let value = value.trim(); + if !value.is_empty() { + Some(value.to_string()) + } else { + None + } + } + + fn try_from_integer(value: u64) -> Option { + Some(value.to_string()) + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Borrowed(self.as_str()) + } +} + +impl MapItem for Id { + fn try_from_string(value: &str) -> Option { + Id::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.as_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.as_string()) + } +} + +impl MapItem for T { + fn try_from_string(value: &str) -> Option { + Self::parse(value) + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Borrowed(self.as_str()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Borrowed(self.as_str()) + } +} + +impl MapItem for IpAddr { + fn try_from_string(value: &str) -> Option { + Self::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl MapItem for IpAddrOrMask { + fn try_from_string(value: &str) -> Option { + Self::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl MapItem for SocketAddr { + fn try_from_string(value: &str) -> Option { + Self::from_str(value).ok() + } + + fn try_from_integer(_: u64) -> Option { + None + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl MapItem for u64 { + fn try_from_string(value: &str) -> Option { + value.parse().ok() + } + + fn try_from_integer(value: u64) -> Option { + Some(value) + } + + fn into_string(self) -> Cow<'static, str> { + Cow::Owned(self.to_string()) + } + + fn as_string(&self) -> Cow<'_, str> { + Cow::Owned(self.to_string()) + } +} + +impl From> for Map { + fn from(vec: Vec) -> Self { + Self(vec) + } +} + +impl IntoIterator for Map { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} diff --git a/crates/registry/src/types/mod.rs b/crates/registry/src/types/mod.rs index 1048f743..c5f04710 100644 --- a/crates/registry/src/types/mod.rs +++ b/crates/registry/src/types/mod.rs @@ -20,6 +20,8 @@ pub mod id; pub mod index; pub mod ipaddr; pub mod ipmask; +pub mod list; +pub mod map; pub mod socketaddr; pub mod string; diff --git a/crates/registry/src/utils/account.rs b/crates/registry/src/utils/account.rs index c4072034..775c4c8d 100644 --- a/crates/registry/src/utils/account.rs +++ b/crates/registry/src/utils/account.rs @@ -4,7 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::schema::prelude::{Account, GroupAccount, UserAccount}; +use types::id::Id; + +use crate::schema::prelude::{ + Account, Credential, GroupAccount, PasswordCredential, SecondaryCredential, UserAccount, +}; impl Account { pub fn into_user(self) -> Option { @@ -23,3 +27,101 @@ impl Account { } } } + +impl UserAccount { + pub fn set_password(&mut self, password: String) { + if let Some(credential) = self.credentials.0.values_mut().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) { + credential.secret = password; + } else { + self.credentials + .push(Credential::Password(PasswordCredential { + secret: password, + ..Default::default() + })); + } + } + + pub fn password_credential(&self) -> Option<&PasswordCredential> { + self.credentials.iter().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) + } + + pub fn password_credential_mut(&mut self) -> Option<&mut PasswordCredential> { + self.credentials.values_mut().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) + } + + pub fn password(&self) -> Option<&str> { + self.password_credential() + .map(|credential| credential.secret.as_str()) + } + + pub fn into_password_credential(self) -> Option { + self.credentials.into_iter().find_map(|credential| { + if let Credential::Password(credential) = credential { + Some(credential) + } else { + None + } + }) + } + + pub fn into_password(self) -> Option { + self.into_password_credential() + .map(|credential| credential.secret) + } +} + +impl Credential { + pub fn credential_id(&self) -> Id { + match self { + Credential::Password(credential) => credential.credential_id, + Credential::AppPassword(credential_properties) => credential_properties.credential_id, + Credential::ApiKey(credential_properties) => credential_properties.credential_id, + } + } + + pub fn set_credential_id(&mut self, credential_id: Id) { + match self { + Credential::Password(credential) => credential.credential_id = credential_id, + Credential::AppPassword(credential_properties) => { + credential_properties.credential_id = credential_id + } + Credential::ApiKey(credential_properties) => { + credential_properties.credential_id = credential_id + } + } + } + + pub fn into_secondary_credential(self) -> Option { + match self { + Credential::AppPassword(credential_properties) => Some(credential_properties), + Credential::ApiKey(credential_properties) => Some(credential_properties), + Credential::Password(_) => None, + } + } + + pub fn as_secondary_credential(&self) -> Option<&SecondaryCredential> { + match self { + Credential::AppPassword(credential_properties) => Some(credential_properties), + Credential::ApiKey(credential_properties) => Some(credential_properties), + Credential::Password(_) => None, + } + } +} diff --git a/crates/registry/src/utils/mod.rs b/crates/registry/src/utils/mod.rs index 3f8ae516..d3ebca00 100644 --- a/crates/registry/src/utils/mod.rs +++ b/crates/registry/src/utils/mod.rs @@ -19,7 +19,7 @@ impl Roles { pub fn role_ids(&self) -> Option<&[Id]> { match self { Roles::Default => None, - Roles::Custom(custom_roles) => Some(&custom_roles.role_ids), + Roles::Custom(custom_roles) => Some(custom_roles.role_ids.as_slice()), } } } diff --git a/crates/registry/src/utils/report.rs b/crates/registry/src/utils/report.rs index 93b1718b..dbd42cfc 100644 --- a/crates/registry/src/utils/report.rs +++ b/crates/registry/src/utils/report.rs @@ -6,7 +6,7 @@ use crate::{ schema::{enums, prelude::UTCDateTime, structs}, - types::ipaddr::IpAddr, + types::{ipaddr::IpAddr, list::List}, }; use mail_auth::report::{tlsrpt::*, *}; use std::borrow::Cow; @@ -318,24 +318,20 @@ impl From for structs::DmarcReportRecord { evaluated_disposition: value.row.policy_evaluated.disposition.into(), evaluated_dkim: value.row.policy_evaluated.dkim.into(), evaluated_spf: value.row.policy_evaluated.spf.into(), - policy_override_reasons: value - .row - .policy_evaluated - .reason - .into_iter() - .map(Into::into) - .collect(), + policy_override_reasons: List::from_iter( + value + .row + .policy_evaluated + .reason + .into_iter() + .map(Into::into), + ), envelope_to: value.identifiers.envelope_to, envelope_from: value.identifiers.envelope_from, header_from: value.identifiers.header_from, - dkim_results: value - .auth_results - .dkim - .into_iter() - .map(Into::into) - .collect(), - spf_results: value.auth_results.spf.into_iter().map(Into::into).collect(), - extensions: value.extensions.into_iter().map(Into::into).collect(), + dkim_results: List::from_iter(value.auth_results.dkim.into_iter().map(Into::into)), + spf_results: List::from_iter(value.auth_results.spf.into_iter().map(Into::into)), + extensions: List::from_iter(value.extensions.into_iter().map(Into::into)), } } } @@ -353,7 +349,7 @@ impl From for Report { begin: value.date_range_begin.timestamp() as u64, end: value.date_range_end.timestamp() as u64, }, - error: value.errors, + error: value.errors.into_inner(), }, policy_published: PolicyPublished { domain: value.policy_domain, @@ -363,7 +359,9 @@ impl From for Report { p: value.policy_disposition.into(), sp: value.policy_subdomain_disposition.into(), testing: value.policy_testing_mode, - fo: failure_reporting_options_to_fo(&value.policy_failure_reporting_options), + fo: failure_reporting_options_to_fo( + value.policy_failure_reporting_options.as_slice(), + ), }, record: value.records.into_iter().map(Into::into).collect(), extensions: value.extensions.into_iter().map(Into::into).collect(), @@ -382,8 +380,8 @@ impl From for structs::DmarcReport { value.report_metadata.date_range.end as i64, ), email: value.report_metadata.email, - errors: value.report_metadata.error, - extensions: value.extensions.into_iter().map(Into::into).collect(), + errors: value.report_metadata.error.into(), + extensions: List::from_iter(value.extensions.into_iter().map(Into::into)), extra_contact_info: value.report_metadata.extra_contact_info, org_name: value.report_metadata.org_name, policy_adkim: value.policy_published.adkim.into(), @@ -392,14 +390,15 @@ impl From for structs::DmarcReport { policy_domain: value.policy_published.domain, policy_failure_reporting_options: fo_to_failure_reporting_options( &value.policy_published.fo, - ), + ) + .into(), policy_subdomain_disposition: value.policy_published.sp.into(), policy_testing_mode: value.policy_published.testing, policy_version: value .policy_published .version_published .map(|v| v.to_string()), - records: value.record.into_iter().map(Into::into).collect(), + records: List::from_iter(value.record.into_iter().map(Into::into)), report_id: value.report_metadata.report_id, } } @@ -516,6 +515,7 @@ impl From for Feedback<'static> { arrival_date: value.arrival_date.map(|d| d.timestamp()), authentication_results: value .authentication_results + .into_inner() .into_iter() .map(Cow::Owned) .collect(), @@ -523,8 +523,18 @@ impl From for Feedback<'static> { original_envelope_id: value.original_envelope_id.map(Cow::Owned), original_mail_from: value.original_mail_from.map(Cow::Owned), original_rcpt_to: value.original_rcpt_to.map(Cow::Owned), - reported_domain: value.reported_domains.into_iter().map(Cow::Owned).collect(), - reported_uri: value.reported_uris.into_iter().map(Cow::Owned).collect(), + reported_domain: value + .reported_domains + .into_inner() + .into_iter() + .map(Cow::Owned) + .collect(), + reported_uri: value + .reported_uris + .into_inner() + .into_iter() + .map(Cow::Owned) + .collect(), reporting_mta: value.reporting_mta.map(Cow::Owned), source_ip: value.source_ip.map(|ip| ip.into_inner()), user_agent: value.user_agent.map(Cow::Owned), @@ -557,7 +567,8 @@ impl From> for structs::ArfFeedbackReport { .authentication_results .into_iter() .map(|s| s.into_owned()) - .collect(), + .collect::>() + .into(), delivery_result: value.delivery_result.into(), dkim_adsp_dns: value.dkim_adsp_dns.map(|s| s.into_owned()), dkim_canonicalized_body: value.dkim_canonicalized_body.map(|s| s.into_owned()), @@ -578,12 +589,14 @@ impl From> for structs::ArfFeedbackReport { .reported_domain .into_iter() .map(|s| s.into_owned()) - .collect(), + .collect::>() + .into(), reported_uris: value .reported_uri .into_iter() .map(|s| s.into_owned()) - .collect(), + .collect::>() + .into(), reporting_mta: value.reporting_mta.map(|s| s.into_owned()), source_ip: value.source_ip.map(IpAddr), source_port: if port == 0 || port > 65535 { @@ -693,9 +706,9 @@ impl From for Policy { Policy { policy: PolicyDetails { policy_type: value.policy_type.into(), - policy_string: value.policy_strings, + policy_string: value.policy_strings.into_inner(), policy_domain: value.policy_domain, - mx_host: value.mx_hosts, + mx_host: value.mx_hosts.into_inner(), }, summary: Summary { total_success: value.total_successful_sessions as u32, @@ -710,12 +723,12 @@ impl From for structs::TlsReportPolicy { fn from(value: Policy) -> Self { structs::TlsReportPolicy { policy_type: value.policy.policy_type.into(), - policy_strings: value.policy.policy_string, + policy_strings: value.policy.policy_string.into(), policy_domain: value.policy.policy_domain, - mx_hosts: value.policy.mx_host, + mx_hosts: value.policy.mx_host.into(), total_successful_sessions: value.summary.total_success as u64, total_failed_sessions: value.summary.total_failure as u64, - failure_details: value.failure_details.into_iter().map(Into::into).collect(), + failure_details: List::from_iter(value.failure_details.into_iter().map(Into::into)), } } } @@ -747,7 +760,7 @@ impl From for structs::TlsReport { ), contact_info: value.contact_info, report_id: value.report_id, - policies: value.policies.into_iter().map(Into::into).collect(), + policies: List::from_iter(value.policies.into_iter().map(Into::into)), } } } diff --git a/crates/services/src/task_manager/imip.rs b/crates/services/src/task_manager/imip.rs index 31f19471..30dd8084 100644 --- a/crates/services/src/task_manager/imip.rs +++ b/crates/services/src/task_manager/imip.rs @@ -79,7 +79,8 @@ async fn send_imip( let sender_domain = imip .messages - .first() + .iter() + .next() .and_then(|msg| msg.from.rsplit('@').next()) .unwrap_or("localhost"); diff --git a/crates/smtp/src/reporting/analysis.rs b/crates/smtp/src/reporting/analysis.rs index 6428d4ef..4b41b456 100644 --- a/crates/smtp/src/reporting/analysis.rs +++ b/crates/smtp/src/reporting/analysis.rs @@ -274,7 +274,7 @@ impl AnalyzeReport for Server { Format::Dmarc(report) => { let mut report = DmarcExternalReport { from, - to, + to: to.into(), subject, member_tenant_id: None, expires_at: UTCDateTime::from_timestamp(expires as i64), @@ -294,7 +294,7 @@ impl AnalyzeReport for Server { Format::Tls(report) => { let mut report = TlsExternalReport { from, - to, + to: to.into(), subject, member_tenant_id: None, expires_at: UTCDateTime::from_timestamp(expires as i64), @@ -314,7 +314,7 @@ impl AnalyzeReport for Server { Format::Arf(report) => { let mut report = ArfExternalReport { from, - to, + to: to.into(), subject, member_tenant_id: None, expires_at: UTCDateTime::from_timestamp(expires as i64), diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs index e0bdc392..c20f3160 100644 --- a/crates/smtp/src/reporting/dmarc.rs +++ b/crates/smtp/src/reporting/dmarc.rs @@ -31,7 +31,7 @@ use registry::{ prelude::{ObjectType, Property}, structs::{DmarcInternalReport, DmarcReport, DmarcReportRecord, Rate}, }, - types::{EnumImpl, datetime::UTCDateTime}, + types::{EnumImpl, datetime::UTCDateTime, map::Map}, }; use std::future::Future; use store::{ @@ -364,7 +364,7 @@ impl DmarcReporting for Server { .dns .verify_dmarc_report_address( &report.domain, - &report.rua, + report.rua.as_slice(), Some(&self.inner.cache.dns_txt), ) .await @@ -573,7 +573,8 @@ impl DmarcReporting for Server { FailureReportingOption::DkimFailure, FailureReportingOption::SpfFailure, ], - }, + } + .into(), policy_subdomain_disposition: policy.sp.into(), policy_testing_mode: policy.testing, policy_version: None, @@ -581,12 +582,14 @@ impl DmarcReporting for Server { ..Default::default() }, policy_identifier: policy_hash, - rua: event - .dmarc_record - .rua() - .iter() - .map(|u| u.uri.clone()) - .collect(), + rua: Map::new( + event + .dmarc_record + .rua() + .iter() + .map(|u| u.uri.clone()) + .collect(), + ), }; report.write_ops(&mut batch, item_id, true); @@ -596,8 +599,15 @@ impl DmarcReporting for Server { // Add record let mut record = DmarcReportRecord::from(event.report_record.clone()); - if let Some(idx) = report.report.records.iter().position(|d| d == &record) { - report.report.records[idx].count += 1; + if let Some(idx) = report + .report + .records + .0 + .inner + .iter() + .position(|d| d.value == record) + { + report.report.records.0.inner[idx].value.count += 1; } else { record.count = 1; report.report.records.push(record); diff --git a/crates/smtp/src/reporting/tls.rs b/crates/smtp/src/reporting/tls.rs index cd8f95fa..3898f03c 100644 --- a/crates/smtp/src/reporting/tls.rs +++ b/crates/smtp/src/reporting/tls.rs @@ -128,7 +128,7 @@ impl TlsReporting for Server { }; // Try delivering report over HTTP - for uri in &report.http_rua { + for uri in report.http_rua.as_slice() { if let Ok(client) = reqwest::Client::builder() .user_agent(USER_AGENT) .timeout(Duration::from_secs(2 * 60)) @@ -329,7 +329,7 @@ impl TlsReporting for Server { .clone(), date_range_end: deliver_at, date_range_start: created_at, - policies: vec![], + policies: Default::default(), }, ..Default::default() }; @@ -341,11 +341,12 @@ impl TlsReporting for Server { let policy = if let Some(policy) = report .policy_identifiers + .as_slice() .iter() .position(|id| *id == policy_hash) - .and_then(|idx| report.report.policies.get_mut(idx)) + .and_then(|idx| report.report.policies.0.inner.get_mut(idx)) { - policy + &mut policy.value } else { // Create policy let mut policy = TlsReportPolicy { @@ -406,27 +407,31 @@ impl TlsReporting for Server { for rua in &event.tls_record.rua { match rua { ReportUri::Mail(mail) => { - if !report.mail_rua.contains(mail) { - report.mail_rua.push(mail.clone()); - } + report.mail_rua.push(mail.clone()); } ReportUri::Http(uri) => { - if !report.http_rua.contains(uri) { - report.http_rua.push(uri.clone()); - } + report.http_rua.push(uri.clone()); } } } report.policy_identifiers.push(policy_hash); report.report.policies.push(policy); - report.report.policies.last_mut().unwrap() + &mut report.report.policies.0.inner.last_mut().unwrap().value }; // Add failure details if let Some(failure) = event.failure.clone().map(TlsFailureDetails::from) { - if let Some(idx) = policy.failure_details.iter().position(|d| d == &failure) { - policy.failure_details[idx].failed_session_count += 1; + if let Some(idx) = policy + .failure_details + .0 + .inner + .iter() + .position(|d| d.value == failure) + { + policy.failure_details.0.inner[idx] + .value + .failed_session_count += 1; } else { policy.failure_details.push(failure); } diff --git a/crates/store/src/backend/mysql/main.rs b/crates/store/src/backend/mysql/main.rs index 2acc56a6..319bd4ef 100644 --- a/crates/store/src/backend/mysql/main.rs +++ b/crates/store/src/backend/mysql/main.rs @@ -13,7 +13,7 @@ use crate::{ }, *, }; -use ::registry::schema::structs; +use ::registry::{schema::structs, utils::OrderedMap}; use mysql_async::{ Conn, OptsBuilder, Pool, PoolConstraints, PoolOpts, SslOpts, prelude::Queryable, }; diff --git a/crates/types/src/id.rs b/crates/types/src/id.rs index df095905..ca2b07e6 100644 --- a/crates/types/src/id.rs +++ b/crates/types/src/id.rs @@ -112,7 +112,7 @@ impl Id { #[inline(always)] pub fn document_id(&self) -> DocumentId { - (self.0 & 0xFFFFFFFF) as DocumentId + self.0 as DocumentId } #[inline(always)] diff --git a/crates/utils/src/map/vec_map.rs b/crates/utils/src/map/vec_map.rs index 5a05ff5d..3b5c7845 100644 --- a/crates/utils/src/map/vec_map.rs +++ b/crates/utils/src/map/vec_map.rs @@ -14,13 +14,13 @@ use std::{borrow::Borrow, cmp::Ordering, fmt, hash::Hash}; #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] pub struct VecMap { - inner: Vec>, + pub inner: Vec>, } #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct KeyValue { - key: K, - value: V, + pub key: K, + pub value: V, } impl Default for VecMap { @@ -186,6 +186,11 @@ impl VecMap { self.inner.iter().map(|kv| &kv.value) } + #[inline(always)] + pub fn last(&self) -> Option<(&K, &V)> { + self.inner.last().map(|kv| (&kv.key, &kv.value)) + } + #[inline(always)] pub fn values_mut(&mut self) -> impl Iterator { self.inner.iter_mut().map(|kv| &mut kv.value) @@ -216,6 +221,13 @@ impl VecMap { }); } + pub fn sort_unstable_by_key(&mut self) + where + K: Ord, + { + self.inner.sort_unstable_by(|a, b| a.key.cmp(&b.key)); + } + pub fn extend(&mut self, iter: impl IntoIterator) { for (k, v) in iter { self.append(k, v); @@ -225,6 +237,14 @@ impl VecMap { pub fn drain(&mut self) -> impl Iterator + '_ { self.inner.drain(..).map(|kv| (kv.key, kv.value)) } + + pub fn into_values(self) -> impl Iterator { + self.inner.into_iter().map(|kv| kv.value) + } + + pub fn into_keys(self) -> impl Iterator { + self.inner.into_iter().map(|kv| kv.key) + } } impl VecMap { @@ -302,7 +322,8 @@ impl FromIterator<(K, V)> for VecMap { where T: IntoIterator, { - let mut map = VecMap::new(); + let iter = iter.into_iter(); + let mut map = VecMap::with_capacity(iter.size_hint().0); for (k, v) in iter { map.append(k, v); }