diff --git a/Cargo.lock b/Cargo.lock index 44a9cf76..41de729a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,15 +602,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -1051,7 +1066,6 @@ dependencies = [ "aes-gcm-siv", "ahash", "arc-swap", - "arcstr", "base64 0.22.1", "bincode 2.0.1", "biscuit", @@ -1129,6 +1143,7 @@ dependencies = [ "x509-parser", "xxhash-rust", "zip", + "zxcvbn", ] [[package]] @@ -1712,6 +1727,37 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.115", +] + [[package]] name = "des" version = "0.8.1" @@ -2116,13 +2162,24 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set 0.5.3", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fancy-regex" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -3769,7 +3826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ "ascii-canvas", - "bit-set", + "bit-set 0.8.0", "ena", "itertools 0.14.0", "lalrpop-util", @@ -7092,7 +7149,7 @@ checksum = "37baebe7a22af73f881c1856c620680444f6ea99482a7734a13f7fc570d17028" dependencies = [ "ahash", "arc-swap", - "fancy-regex", + "fancy-regex 0.17.0", "hashify", "mail-builder", "mail-parser", @@ -9950,3 +10007,20 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zxcvbn" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" +dependencies = [ + "chrono", + "derive_builder", + "fancy-regex 0.13.0", + "itertools 0.13.0", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index bb8babbd..8bd47eba 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -77,13 +77,13 @@ tinyvec = { version = "1.10.0", features = ["alloc"] } compact_str = { version = "0.9.0", features = ["rkyv", "serde"] } lz4_flex = { version = "0.12", features = ["frame"], default-features = false } hickory-proto = "0.24" -arcstr = "1.2.0" nohash-hasher = "0.2.0" quick_cache = "0.6.9" rasn = "0.10" rasn-cms = "0.10" rasn-pkix = "0.10" sequoia-openpgp = { version = "2.0", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] } +zxcvbn = "3.1.0" [target.'cfg(unix)'.dependencies] privdrop = "0.5.3" diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 9d5adc47..237897ab 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -9,7 +9,6 @@ use crate::{ network::limiter::ConcurrencyLimiter, storage::{ObjectQuota, TenantQuota}, }; -use arcstr::ArcStr; use directory::Credentials; use quick_cache::Equivalent; use registry::{ @@ -49,7 +48,7 @@ pub struct EmailAddressRef<'x> { domain_id: u32, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EmailCache { Account(u32), MailingList(u32), @@ -57,7 +56,7 @@ pub enum EmailCache { #[derive(Debug, Clone)] pub struct DomainCache { - pub names: Box<[ArcStr]>, + pub names: Box<[Box]>, pub id: u32, pub id_directory: Option, pub id_tenant: Option, @@ -102,8 +101,7 @@ pub struct RoleCache { #[derive(Debug, Clone)] pub struct MailingListCache { - //pub addresses: Box<[Box]>, - pub recipients: Arc<[ArcStr]>, + pub recipients: Arc<[Box]>, } #[derive(Debug, Clone)] diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index a65e6c13..d68d63a6 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -229,6 +229,8 @@ impl Default for DefaultPermissions { | Permission::AuthenticateWithAlias | Permission::InteractAi => { default.user.push(permission); + default.superuser.push(permission); + default.tenant.push(permission); } Permission::Impersonate | Permission::UnlimitedRequests @@ -250,7 +252,10 @@ impl Default for DefaultPermissions { || name.starts_with("email") || name.starts_with("dav") || name.starts_with("sieve") - || name.starts_with("sysMaskedEmail") + { + default.user.push(permission); + default.group.push(permission); + } else if name.starts_with("sysMaskedEmail") || name.starts_with("sysArchivedItem") || name.starts_with("sysAccountSettings") || name.starts_with("sysPublicKey") @@ -258,8 +263,10 @@ impl Default for DefaultPermissions { { default.user.push(permission); default.group.push(permission); + default.superuser.push(permission); } else if name.starts_with("sysCredential") { default.user.push(permission); + default.superuser.push(permission); } else if name.starts_with("sysDomain") || name.starts_with("sysDkimSignature") || name.starts_with("sysAccount") diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index 4a547aed..80a7035e 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -197,6 +197,7 @@ impl CacheInvalidationBuilder { impl Server { pub async fn invalidate_caches(&self, changes: CacheInvalidationBuilder) -> trc::Result<()> { let mut changes = changes.changes; + let c = println!("Invalidating caches for changes: {:?}", changes); if changes.is_empty() { return Ok(()); } diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index 5d65514f..e34bf25b 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -22,7 +22,6 @@ use crate::{ encryption::{EncryptionMethod, parse_public_key}, }, }; -use arcstr::ArcStr; use registry::{ schema::{ enums::{Locale, StorageQuota, TenantStorageQuota}, @@ -32,13 +31,13 @@ use registry::{ Permissions, PublicKey, Role, SubAddressing, Tenant, }, }, - types::{EnumImpl, id::ObjectId}, + types::id::ObjectId, }; use std::{borrow::Cow, sync::Arc}; use store::{ - U64_LEN, ValueKey, + U64_LEN, registry::{RegistryQuery, bootstrap::Bootstrap}, - write::{RegistryClass, ValueClass, key::KeySerializer, now}, + write::{key::KeySerializer, now}, }; use trc::AddContext; use types::id::Id; @@ -57,17 +56,18 @@ impl Server { } else { let domain_names_negative = &self.inner.cache.domain_names_negative; if domain_names_negative.get(domain).is_none() { - if let Some(domain_id) = self + if let Some(domain) = self .registry() - .query::>( - RegistryQuery::new(ObjectType::Domain).equal(Property::Name, domain), + .primary_key( + ObjectType::Domain.into(), + Property::Name, + domain.as_bytes().to_vec(), ) - .await? - .into_iter() - .next() + .await + .caused_by(trc::location!())? { // Cache positive result - let domain_id = domain_id.document_id(); + let domain_id = domain.id().document_id(); let domain = self.domain_by_id(domain_id).await?; if let Some(domain) = &domain { for name in domain.names.iter() { @@ -131,9 +131,14 @@ impl Server { }; let cache = Arc::new(DomainCache { - names: [ArcStr::from(domain.name)] + names: [domain.name.into_boxed_str()] .into_iter() - .chain(domain.aliases.into_iter().map(ArcStr::from)) + .chain( + domain + .aliases + .into_iter() + .map(|alias| alias.into_boxed_str()), + ) .collect(), id: domain_id, id_directory: domain.directory_id.map(|id| id.document_id()), @@ -164,18 +169,16 @@ impl Server { .get(&EmailAddressRef::new(local_part, domain_id)) .is_none() { - let key = ValueKey::from(ValueClass::Registry(RegistryClass::PrimaryKey { - object_id: None, - index_id: Property::Email.to_id(), - key: KeySerializer::new(local_part.len() + U64_LEN) - .write(local_part.as_bytes()) - .write(domain_id as u64) - .finalize(), - })); - if let Some(object) = self - .store() - .get_value::(key) + .registry() + .primary_key( + None, + Property::Email, + KeySerializer::new(local_part.len() + U64_LEN) + .write(local_part.as_bytes()) + .write(domain_id as u64) + .finalize(), + ) .await .caused_by(trc::location!())? { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 5a64df23..9fb074b0 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -24,7 +24,6 @@ use crate::{ }; use ahash::{AHashMap, AHashSet}; use arc_swap::ArcSwap; -use arcstr::ArcStr; use auth::oauth::config::OAuthConfig; use calcard::common::timezone::Tz; use config::{ @@ -182,7 +181,7 @@ pub struct Caches { pub emails: Cache, pub emails_negative: CacheWithTtl, - pub domain_names: Cache, + pub domain_names: Cache, u32>, pub domain_names_negative: CacheWithTtl, ()>, pub domains: Cache>, diff --git a/crates/common/src/network/mod.rs b/crates/common/src/network/mod.rs index 26ef2d21..8295c301 100644 --- a/crates/common/src/network/mod.rs +++ b/crates/common/src/network/mod.rs @@ -10,7 +10,6 @@ use crate::{ config::server::ServerProtocol, expr::{functions::ResolveVariable, *}, }; -use arcstr::ArcStr; use compact_str::ToCompactString; use registry::{schema::enums::ExpressionVariable, types::ipmask::IpAddrOrMask}; use rustls::ServerConfig; @@ -39,7 +38,7 @@ pub mod tls; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] pub enum RcptResolution { Accept, - Expand(Arc<[ArcStr]>), + Expand(Arc<[Box]>), Rewrite(String), #[default] UnknownRecipient, diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index ae79a370..315fa686 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -49,6 +49,9 @@ pub struct Security { pub default_role_ids_tenant: Vec, pub password_hash_algorithm: PasswordHashAlgorithm, + pub password_max_length: u32, + pub password_min_length: u32, + pub password_min_strength: u8, } #[derive(Default)] @@ -148,6 +151,9 @@ impl Security { 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, + password_max_length: auth.password_max_length as u32, + password_min_length: auth.password_min_length as u32, + password_min_strength: auth.password_min_strength as u8, } } } @@ -324,6 +330,31 @@ impl Server { .iter() .any(|network| network.matches(ip))) } + + pub fn is_secure_password(&self, password: &str, user_inputs: &[&str]) -> Result<(), String> { + if (password.len() as u32) > self.core.network.security.password_max_length { + Err(format!( + "Password must be at most {} characters long.", + self.core.network.security.password_max_length + )) + } else if (password.len() as u32) < self.core.network.security.password_min_length { + Err(format!( + "Password must be at least {} characters long.", + self.core.network.security.password_min_length + )) + } else if self.core.network.security.password_min_strength > 0 { + let entropy = zxcvbn::zxcvbn(password, user_inputs); + if u8::from(entropy.score()) >= self.core.network.security.password_min_strength { + Ok(()) + } else if let Some(feedback) = entropy.feedback() { + Err(format!("Password is too weak. {feedback}")) + } else { + Err("Password is too weak.".to_string()) + } + } else { + Ok(()) + } + } } impl BlockedIps { diff --git a/crates/jmap-proto/src/error/set.rs b/crates/jmap-proto/src/error/set.rs index d16f62c0..60fc67dc 100644 --- a/crates/jmap-proto/src/error/set.rs +++ b/crates/jmap-proto/src/error/set.rs @@ -53,7 +53,7 @@ pub enum InvalidProperty { Path(Vec>), } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SetErrorType { #[serde(rename = "forbidden")] Forbidden, diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs index 63c08fb8..bb0cbfe3 100644 --- a/crates/jmap/src/registry/mapping/account.rs +++ b/crates/jmap/src/registry/mapping/account.rs @@ -437,19 +437,6 @@ pub(crate) async fn account_set( } } - 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(), @@ -463,6 +450,30 @@ pub(crate) async fn account_set( ); continue 'outer; } + + if credential.secret != old_credential.secret { + if let Err(err) = + set.server.is_secure_password(&credential.secret, &[]) + { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(Property::Secret) + .with_description(err), + ); + continue 'outer; + } + credential.secret = hash_secret( + set.server + .core + .network + .security + .password_hash_algorithm, + std::mem::take(&mut credential.secret), + ) + .await + .caused_by(trc::location!())?; + } } else { set.response.not_updated.append( id, diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs index e6a02065..4a01ea9f 100644 --- a/crates/jmap/src/registry/mapping/principal.rs +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -104,22 +104,20 @@ pub(crate) async fn validate_account( } 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 { + if let Err(err) = + set.server.is_secure_password(&credential.secret, &[]) + { return Ok(Err(SetError::invalid_properties() .with_property(Property::Secret) - .with_description("Password cannot be empty."))); + .with_description(err))); } + + credential.secret = hash_secret( + set.server.core.network.security.password_hash_algorithm, + std::mem::take(&mut credential.secret), + ) + .await + .caused_by(trc::location!())?; } } ( @@ -240,7 +238,11 @@ async fn validate_credential_creation( .with_description("Only one password credential is allowed."))); } - if credential.secret.is_empty() { + if let Err(err) = server.is_secure_password(&credential.secret, &[]) { + Ok(Err(SetError::invalid_properties() + .with_property(Property::Secret) + .with_description(err))) + } else { credential.secret = hash_secret( server.core.network.security.password_hash_algorithm, std::mem::take(&mut credential.secret), @@ -248,10 +250,6 @@ async fn validate_credential_creation( .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(_) => { diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index a377180b..1beb3307 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -90,8 +90,9 @@ impl RegistrySet for Server { let has_account_id = (object_flags & OBJ_FILTER_ACCOUNT) != 0; let is_tenant_filtered = (object_flags & OBJ_FILTER_TENANT) != 0 && access_token.tenant_id().is_some(); - let is_account_filtered = - has_account_id && !access_token.has_permission(Permission::Impersonate); + let can_set_tenant = access_token.tenant_id().is_none(); + let can_set_account = access_token.has_permission(Permission::Impersonate); + let is_account_filtered = has_account_id && !can_set_account; // Build response let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; @@ -316,77 +317,28 @@ impl RegistrySet for Server { let is_create = matches!(modification, Modification::Create { .. }); let mut unpatched_properties = VecMap::new(); - for (key, value) in value.into_expanded_object() { - let ptr = match (key, &modification) { - (Key::Property(prop), _) => { - JsonPointer::new(vec![JsonPointerItem::Key(Key::Property(prop))]) - } - (Key::Borrowed(other), Modification::Update { .. }) => { - JsonPointer::parse(other) - } - (Key::Owned(other), Modification::Update { .. }) => { - JsonPointer::parse(&other) - } - (key, Modification::Create { .. }) => { - set.failed( - modification, - SetError::invalid_properties().with_property(key.into_owned()), - ); - continue 'outer; - } - }; - - if is_tenant_filtered || is_account_filtered { - match ptr.last().and_then(|p| p.as_property_key()) { - Some(Property::MemberTenantId) => { - // SPDX-SnippetBegin - // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - // SPDX-License-Identifier: LicenseRef-SEL - #[cfg(feature = "enterprise")] - if access_token.tenant_id().is_some() { - continue; - } - // SPDX-SnippetEnd - - #[cfg(not(feature = "enterprise"))] - continue; - } - Some(Property::AccountId) => { - set.failed( - modification, - SetError::forbidden() - .with_property(Property::AccountId) - .with_description("Cannot change server-set property"), - ); - continue 'outer; - } - _ => {} - } - } - + if is_create { // Patch object - match new_object - .patch(JsonPointerPatch::new(&ptr).with_create(is_create), value) - { + match new_object.patch( + JsonPointerPatch::new(&JsonPointer::new(vec![])) + .with_create(true) + .with_can_set_tenant(can_set_tenant) + .with_can_set_account(can_set_account), + 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); - } + unpatched_properties = properties; } Err(err) => { set.failed(modification, err.into()); continue 'outer; } } - } - if is_create { // Add tenantId for tenant filtered objects if is_tenant_filtered && let Some(tenant_id) = set.access_token.tenant_id() { @@ -397,6 +349,43 @@ impl RegistrySet for Server { if has_account_id { new_object.inner.set_account_id(set.account_id.into()); } + } else { + 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), + }; + + // Patch object + match new_object.patch( + JsonPointerPatch::new(&ptr) + .with_create(false) + .with_can_set_tenant(can_set_tenant) + .with_can_set_account(can_set_account), + value, + ) { + Ok(MaybeUnpatched::Patched) => {} + Ok(MaybeUnpatched::Unpatched { property, value }) => { + unpatched_properties.append(property, value); + } + Ok(MaybeUnpatched::UnpatchedMany { properties }) => { + if unpatched_properties.is_empty() { + unpatched_properties = properties; + } else { + unpatched_properties.extend(properties); + } + } + Err(err) => { + set.failed(modification, err.into()); + continue 'outer; + } + } + } } // Validate objects diff --git a/crates/registry/src/jmap/mod.rs b/crates/registry/src/jmap/mod.rs index 8a451b17..a1d31cbb 100644 --- a/crates/registry/src/jmap/mod.rs +++ b/crates/registry/src/jmap/mod.rs @@ -45,6 +45,8 @@ pub struct JsonPointerPatch<'x> { pos: usize, validators: &'x [StringValidator], is_create: bool, + can_set_tenant: bool, + can_set_account: bool, } pub trait RegistryJsonPatch: Debug + Default { diff --git a/crates/registry/src/jmap/patch.rs b/crates/registry/src/jmap/patch.rs index a179982e..89906f90 100644 --- a/crates/registry/src/jmap/patch.rs +++ b/crates/registry/src/jmap/patch.rs @@ -27,6 +27,19 @@ impl<'x> JsonPointerPatch<'x> { pos: 0, validators: &[], is_create: false, + can_set_tenant: false, + can_set_account: false, + } + } + + pub fn cloned_with_ptr(&self, ptr: &'x JsonPointer) -> Self { + Self { + ptr, + pos: 0, + validators: &[], + is_create: self.is_create, + can_set_tenant: self.can_set_tenant, + can_set_account: self.can_set_account, } } @@ -35,7 +48,9 @@ impl<'x> JsonPointerPatch<'x> { ptr: self.ptr, pos: 0, validators: &[], - is_create: false, + is_create: self.is_create, + can_set_tenant: self.can_set_tenant, + can_set_account: self.can_set_account, } } @@ -44,6 +59,16 @@ impl<'x> JsonPointerPatch<'x> { self } + pub fn with_can_set_tenant(mut self, can_set_tenant: bool) -> Self { + self.can_set_tenant = can_set_tenant; + self + } + + pub fn with_can_set_account(mut self, can_set_account: bool) -> Self { + self.can_set_account = can_set_account; + self + } + pub fn with_validators(mut self, validators: &'x [StringValidator]) -> Self { self.validators = validators; self @@ -98,9 +123,31 @@ impl<'x> JsonPointerPatch<'x> { pub fn assert_server_set(self) -> PatchResult<'static> { Err(PatchError::new( self.cloned(), - "Cannot modify server-set property", + "Cannot modify server set property", )) } + + pub fn assert_can_set_tenant(self) -> Result { + if self.can_set_tenant { + Ok(self) + } else { + Err(PatchError::new( + self.cloned(), + "Cannot modify memberTenantId property", + )) + } + } + + pub fn assert_can_set_account(self) -> Result { + if self.can_set_account { + Ok(self) + } else { + Err(PatchError::new( + self.cloned(), + "Cannot modify accountId property", + )) + } + } } impl RegistryJsonPatch for Option { @@ -326,7 +373,7 @@ impl RegistryJsonPatch for T { if let Some(property) = key.as_property() { if *property != Property::Type { ptr.as_mut_slice()[0] = JsonPointerItem::Key(Key::Property(*property)); - match self.patch_property(JsonPointerPatch::new(&ptr), value) { + match self.patch_property(pointer.cloned_with_ptr(&ptr), value) { Ok(MaybeUnpatched::Patched) => {} Ok(MaybeUnpatched::Unpatched { property, value }) => { unpatched.append(property, value); @@ -336,9 +383,20 @@ impl RegistryJsonPatch for T { } Err(mut e) => { if !e.path.is_empty() { - e.path = format!("{}/{}", e.path, property.as_str()); + if !pointer.ptr.as_slice().is_empty() { + e.path = format!("{}/{}", pointer.path(), e.path); + } } else { - e.path = property.as_str().to_string(); + e.path = JsonPointer::new( + pointer + .ptr + .as_slice() + .iter() + .cloned() + .chain([JsonPointerItem::Key(Key::Property(*property))]) + .collect(), + ) + .to_string(); } return Err(e); } diff --git a/crates/registry/src/schema/mod.rs b/crates/registry/src/schema/mod.rs index 3c34d33b..a902fd96 100644 --- a/crates/registry/src/schema/mod.rs +++ b/crates/registry/src/schema/mod.rs @@ -129,3 +129,9 @@ impl Object { Object { inner, revision } } } + +impl From for String { + fn from(value: Property) -> Self { + value.as_str().to_string() + } +} diff --git a/crates/registry/src/types/error.rs b/crates/registry/src/types/error.rs index e1667adc..483426e5 100644 --- a/crates/registry/src/types/error.rs +++ b/crates/registry/src/types/error.rs @@ -11,7 +11,7 @@ use crate::{ }; use std::{borrow::Cow, fmt::Display}; -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(tag = "type")] pub enum ValidationError { Invalid { property: Property, value: String }, diff --git a/crates/registry/src/types/id.rs b/crates/registry/src/types/id.rs index f24f71da..fce10dc6 100644 --- a/crates/registry/src/types/id.rs +++ b/crates/registry/src/types/id.rs @@ -20,7 +20,7 @@ use types::{ id::Id, }; -#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash, serde::Serialize)] +#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct ObjectId { object: ObjectType, id: Id, diff --git a/crates/store/src/registry/query.rs b/crates/store/src/registry/query.rs index d76fe974..d7898b71 100644 --- a/crates/store/src/registry/query.rs +++ b/crates/store/src/registry/query.rs @@ -18,8 +18,8 @@ use crate::{ }; use ahash::AHashSet; use registry::{ - schema::prelude::{OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, ObjectType, Property}, - types::EnumImpl, + schema::prelude::{OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, ObjectType, Property}, + types::{EnumImpl, id::ObjectId}, }; use roaring::RoaringBitmap; use std::{borrow::Cow, ops::BitAndAssign}; @@ -144,9 +144,37 @@ impl RegistryStore { } pub async fn count_object(&self, object_type: ObjectType) -> trc::Result { - self.query::(RegistryQuery::new(object_type)) + if object_type.flags() & OBJ_SINGLETON == 0 { + self.query::(RegistryQuery::new(object_type)) + .await + .map(|r| r.0) + } else { + self.store() + .key_exists(ValueKey::from(RegistryClass::Item { + object_id: object_type.to_id(), + item_id: Id::singleton().id(), + })) + .await + .caused_by(trc::location!()) + .map(|exists| if exists { 1 } else { 0 }) + } + } + + pub async fn primary_key( + &self, + object_type: Option, + property: Property, + key: Vec, + ) -> trc::Result> { + self.store() + .get_value::(ValueKey::from(ValueClass::Registry( + RegistryClass::PrimaryKey { + object_id: object_type.map(|obj| obj.to_id()), + index_id: property.to_id(), + key, + }, + ))) .await - .map(|r| r.0) } pub async fn sort_by_index( diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 6c5f7d8d..288c2817 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -266,37 +266,79 @@ impl ServerCertVerifier for DummyVerifier { } } +static NIL_CHAR: char = char::from_u32(0).unwrap(); + // Basic email sanitizer pub fn sanitize_email(email: &str) -> Option { let mut result = String::with_capacity(email.len()); - let mut found_local = false; - let mut found_domain = false; - let mut last_ch = char::from(0); + let mut last_ch = NIL_CHAR; + let mut chars = email.chars(); - for ch in email.chars() { - if !ch.is_whitespace() { - if ch == '@' { - if !result.is_empty() && !found_local { - found_local = true; + for ch in chars.by_ref() { + match ch { + '.' | '+' | '-' | '_' => { + if !last_ch.is_alphanumeric() { + return None; + } + result.push(ch); + } + ' ' | '\x09'..='\x0d' => continue, + '@' => { + if result.is_empty() || last_ch == '.' { + return None; + } + last_ch = ch; + result.push(ch); + break; + } + _ => { + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + result.push(ch); + } + } else if ch.is_alphanumeric() { + result.push(ch); } else { return None; } - } else if ch == '.' { - if !(last_ch.is_alphanumeric() || last_ch == '-' || last_ch == '_') { - return None; - } else if found_local { - found_domain = true; - } - } - last_ch = ch; - for ch in ch.to_lowercase() { - result.push(ch); } } + + last_ch = ch; } - if found_domain - && last_ch != '.' + if last_ch != '@' { + return None; + } + + last_ch = NIL_CHAR; + + for ch in chars { + match ch { + '.' | '-' | '_' => { + if !last_ch.is_alphanumeric() { + return None; + } + result.push(ch); + } + ' ' | '\x09'..='\x0d' => continue, + _ => { + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + result.push(ch); + } + } else if ch.is_alphanumeric() { + result.push(ch); + } else { + return None; + } + } + } + + last_ch = ch; + } + + if last_ch.is_alphanumeric() && psl::domain(result.as_bytes()).is_some_and(|d| d.suffix().typ().is_some()) { Some(result) @@ -307,23 +349,34 @@ pub fn sanitize_email(email: &str) -> Option { pub fn sanitize_email_local(local: &str) -> Option { let mut result = String::with_capacity(local.len()); - let mut last_ch = char::from(0); + let mut last_ch = NIL_CHAR; for ch in local.chars() { - if !ch.is_whitespace() { - if ch.is_alphanumeric() { - for ch in ch.to_lowercase() { - result.push(ch); + match ch { + '.' | '+' | '-' | '_' => { + if !last_ch.is_alphanumeric() { + return None; + } + result.push(ch); + } + ' ' | '\x09'..='\x0d' => continue, + _ => { + if ch.is_uppercase() { + for ch in ch.to_lowercase() { + result.push(ch); + } + } else if ch.is_alphanumeric() { + result.push(ch); + } else { + return None; } - } else if result.is_empty() || !last_ch.is_alphanumeric() { - return None; } - - last_ch = ch; } + + last_ch = ch; } - if last_ch.is_alphanumeric() { + if !result.is_empty() && last_ch != '.' { Some(result) } else { None diff --git a/resources/html-templates/login.html b/resources/html-templates/login.html new file mode 100644 index 00000000..0199074a --- /dev/null +++ b/resources/html-templates/login.html @@ -0,0 +1,283 @@ + + + + + + + Sign in + + + + +
+ +

Sign in

+

Enter your credentials to continue

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/tests/src/system/authentication.rs b/tests/src/system/authentication.rs new file mode 100644 index 00000000..928cbdde --- /dev/null +++ b/tests/src/system/authentication.rs @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::CredentialType, + prelude::{ObjectType, Property}, + structs::{Account, Credential, PasswordCredential, SecondaryCredential, UserAccount}, + }, + types::{EnumImpl, list::List}, +}; +use serde_json::json; + +use crate::utils::server::TestServer; + +pub async fn test(test: &TestServer) { + let admin = test.account("admin@example.org"); + let domain_id = admin.find_or_create_domain("example.org").await; + + // Weak passwords should be rejected + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "12345".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Password must be at least 8 characters long."); + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "12345678".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains(concat!( + "Password is too weak. This is a top-10 common password. ", + "Add another word or two. Uncommon words are better." + )); + + // Adding secondary credentials should not be allowed + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::AppPassword(SecondaryCredential { + description: "Test app password".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Secondary credentials cannot be set directly"); + admin + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::ApiKey(SecondaryCredential { + description: "Test API key".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Secondary credentials cannot be set directly"); + + // Creating a user with a valid password should succeed + let user_id = admin + .registry_create_object(Account::User(UserAccount { + name: "user".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "this is a very strong password".to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await; + validate_password("user@example.org", "this is a very strong password", true).await; + validate_password("user@example.org", "wrong password", false).await; + + // Change password as admin + admin + .registry_update_object_expect_err( + ObjectType::Account, + user_id, + json!({ + "credentials/0/secret": "12345" + }), + ) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Password must be at least 8 characters long."); + admin + .registry_update_object( + ObjectType::Account, + user_id, + json!({ + "credentials/0/secret": "very strong password indeed" + }), + ) + .await; + validate_password("user@example.org", "this is a very strong password", false).await; + validate_password("user@example.org", "very strong password indeed", true).await; + + // Change password as user + let user = crate::utils::account::Account::new( + "user@example.org", + "very strong password indeed", + &[], + user_id, + ) + .await; + let credential_id = user + .registry_query( + ObjectType::Credential, + [(Property::Type, CredentialType::Password.as_str())], + Vec::<&str>::new(), + ) + .await[0]; + + // Password updates should require the old password + user.registry_update_object_expect_err( + ObjectType::Credential, + credential_id, + json!({ + Property::Secret: "12345" + }), + ) + .await + .assert_type(SetErrorType::Forbidden) + .assert_description_contains( + "Current secret must be provided to change the password or OTP auth.", + ); + + user.registry_query( + ObjectType::Credential, + [(Property::Type, CredentialType::Password.as_str())], + Vec::<&str>::new(), + ) + .await[0]; + + // Password policies should be enforced when changing password + /*user.registry_update_object_expect_err( + ObjectType::Credential, + credential_id, + json!({ + Property::CurrentSecret: "very strong password indeed", + Property::Secret: "12345" + }), + ) + .await + .assert_type(SetErrorType::InvalidProperties) + .assert_description_contains("Password must be at least 8 characters long.");*/ +} + +pub async fn validate_password(username: &str, password: &str, is_valid: bool) { + let response = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .get("https://127.0.0.1:8899/.well-known/jmap") + .basic_auth(username, Some(password)) + .send() + .await + .unwrap(); + + let status = response.status(); + if status.is_success() != is_valid { + let text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + panic!( + "Expected password to be {}. Server responded with status {}: {}", + if is_valid { "valid" } else { "invalid" }, + status, + text + ); + } +} diff --git a/tests/src/system/directory.rs b/tests/src/system/directory.rs index d0cfcce6..4b3500b3 100644 --- a/tests/src/system/directory.rs +++ b/tests/src/system/directory.rs @@ -5,734 +5,399 @@ */ use crate::utils::server::TestServer; +use common::auth::{ACCOUNT_IS_USER, EmailAddress, EmailCache}; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::{AccountType, StorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + Account, Credential, Domain, EmailAlias, GroupAccount, MailingList, PasswordCredential, + UserAccount, + }, + }, + types::{EnumImpl, list::List, map::Map}, +}; +use serde_json::json; +use std::sync::Arc; +use utils::map::vec_map::VecMap; pub async fn test(test: &TestServer) { - // A principal without name should fail - /*assert_eq!( - store - .create_principal(PrincipalSet::default(), None, None) - .await, - Err(manage::err_missing(PrincipalField::Name)) - ); + let account = test.account("admin@example.org"); - // Basic account creation - let john_id = store - .create_principal( - TestPrincipal { - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["secret".into(), "$app$secret2".into()], - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap() - .id; - - // Two accounts with the same name should fail - assert_eq!( - store - .create_principal( - TestPrincipal { - name: "john".into(), - ..Default::default() - } - .into(), - None, - None - ) - .await, - Err(manage::err_exists(PrincipalField::Name, "john")) - ); - - // An account using a non-existent domain should fail - assert_eq!( - store - .create_principal( - TestPrincipal { - name: "jane".into(), - emails: vec!["jane@example.org".into()], - ..Default::default() - } - .into(), - None, - None - ) - .await, - Err(manage::not_found("example.org")) - ); - - // Create a domain name - store - .create_principal( - TestPrincipal { - name: "example.org".into(), - typ: Type::Domain, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap(); - assert!(store.is_local_domain("example.org").await.unwrap()); - assert!(!store.is_local_domain("otherdomain.org").await.unwrap()); - - // Add an email address - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john@example.org".into()), - ) - ])) - .await - .is_ok() - ); - assert_eq!( - store.rcpt("john@example.org").await.unwrap(), - RcptType::Mailbox - ); - assert_eq!( - store.email_to_id("john@example.org").await.unwrap(), - Some(john_id) - ); - - // Using non-existent domain should fail - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john@otherdomain.org".into()), - ) - ])) - .await, - Err(manage::not_found("otherdomain.org")) - ); - - // Create an account with an email address - let jane_id = store - .create_principal( - TestPrincipal { - name: "jane".into(), - description: Some("Jane Doe".into()), - secrets: vec!["my_secret".into(), "$app$my_secret2".into()], - emails: vec!["jane@example.org".into()], - quota: 123, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap() - .id; - - assert_eq!( - store.rcpt("jane@example.org").await.unwrap(), - RcptType::Mailbox - ); - assert_eq!( - store.rcpt("jane@otherdomain.org").await.unwrap(), - RcptType::Invalid - ); - assert_eq!( - store.email_to_id("jane@example.org").await.unwrap(), - Some(jane_id) - ); - assert_eq!(store.vrfy("jane").await.unwrap(), vec!["jane@example.org"]); - assert_eq!( - store - .query( - QueryParams::credentials(&Credentials::new("jane".into(), "my_secret".into())) - .with_return_member_of(true) - ) - .await - .unwrap() - .map(|p| p.into_test()), - Some(TestPrincipal { - id: jane_id, - name: "jane".into(), - description: Some("Jane Doe".into()), - emails: vec!["jane@example.org".into()], - secrets: vec!["my_secret".into(), "$app$my_secret2".into()], - quota: 123, + // Create a domain and make sure it's in the cache + let domain_id = account + .registry_create_object(Domain { + name: "example.com".to_string(), + aliases: Map::new(vec!["beispiel.de".to_string()]), + is_enabled: true, + catch_all_address: Some("catchy@example.com".to_string()), ..Default::default() }) - ); + .await; + let domain_cache = test + .server + .domain_by_id(domain_id.document_id()) + .await + .unwrap() + .unwrap(); assert_eq!( - store - .query( - QueryParams::credentials(&Credentials::new("jane".into(), "wrong_password".into())) - .with_return_member_of(true) - ) - .await - .unwrap(), - None + &domain_cache.names, + &Box::from_iter(["example.com".into(), "beispiel.de".into()]) + ); + assert_eq!(domain_cache.id, domain_id.document_id()); + assert_eq!( + domain_cache.catch_all.as_deref(), + Some("catchy@example.com") ); - // Duplicate email address should fail + // Multiple domains with the same name should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "example.com".to_string(), + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // Invalid local part should not be allowed + account + .registry_create_object_expect_err(Account::User(UserAccount { + name: "!invalid".to_string(), + domain_id, + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "hello world".to_string(), + ..Default::default() + })]), + aliases: List::from_iter([EmailAlias { + name: "!invalid".to_string(), + domain_id, + enabled: true, + ..Default::default() + }]), + ..Default::default() + })) + .await + .assert_type(SetErrorType::InvalidPatch) + .assert_description_contains("Invalid email local part"); + + // Valid account creation with local part sanitization + let account_id = account + .registry_create_object(Account::User(UserAccount { + name: " john doe".to_string(), + domain_id, + description: "John 'Johnny-D' Doe".to_string().into(), + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: "hello world".to_string(), + ..Default::default() + })]), + aliases: List::from_iter([EmailAlias { + name: "jdoe".to_string(), + domain_id, + enabled: true, + ..Default::default() + }]), + quotas: VecMap::from_iter([ + (StorageQuota::MaxDiskQuota, 1024u64), + (StorageQuota::MaxEmails, 100u64), + ]), + ..Default::default() + })) + .await; + let account_cache = test.server.account(account_id.document_id()).await.unwrap(); + assert_eq!(account_cache.name.as_ref(), "johndoe@example.com"); assert_eq!( - store - .create_principal( - TestPrincipal { - name: "janeth".into(), - description: Some("Janeth Doe".into()), - emails: vec!["jane@example.org".into()], - ..Default::default() - } - .into(), - None, - None - ) - .await, - Err(manage::err_exists( - PrincipalField::Emails, - "jane@example.org" - )) + account_cache.description.as_deref(), + Some("John 'Johnny-D' Doe") ); + assert_eq!(account_cache.id, account_id.document_id()); + assert_eq!(account_cache.quota_disk, 1024); + assert_eq!( + account_cache + .quota_objects + .as_ref() + .unwrap() + .get(StorageQuota::MaxEmails), + 100 + ); + assert_eq!( + account_cache.addresses, + vec![ + EmailAddress { + local_part: "johndoe".into(), + domain_id: domain_id.document_id(), + }, + EmailAddress { + local_part: "jdoe".into(), + domain_id: domain_id.document_id(), + } + ] + .into_boxed_slice() + ); + assert!(account_cache.flags & ACCOUNT_IS_USER != 0); + + // Duplicate account names should not be allowed + account + .registry_create_object_expect_err(Account::User(UserAccount { + name: "johndoe".to_string(), + domain_id, + ..Default::default() + })) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + account + .registry_create_object_expect_err(Account::User(UserAccount { + name: "jdoe".to_string(), + domain_id, + ..Default::default() + })) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + account + .registry_create_object_expect_err(Account::Group(GroupAccount { + name: "jdoe".to_string(), + domain_id, + ..Default::default() + })) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + account + .registry_create_object_expect_err(MailingList { + name: "jdoe".to_string(), + domain_id, + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // Create a group and add it to the account + let group_id = account + .registry_create_object(Account::Group(GroupAccount { + name: "sales".to_string(), + domain_id, + ..Default::default() + })) + .await; + let account_cache = test.server.account(group_id.document_id()).await.unwrap(); + assert_eq!(account_cache.name.as_ref(), "sales@example.com"); + assert!(account_cache.flags & ACCOUNT_IS_USER == 0); + account + .registry_update_object( + ObjectType::Account, + account_id, + json!({ + Property::MemberGroupIds: { + group_id: true + } + }), + ) + .await; + let account_cache = test.server.account(account_id.document_id()).await.unwrap(); + assert_eq!( + account_cache.id_member_of.as_ref(), + &[group_id.document_id()] + ); + + // Linking invalid groups should not be allowed + account + .registry_update_object_expect_err( + ObjectType::Account, + account_id, + json!({ + Property::MemberGroupIds: { + account_id: true + } + }), + ) + .await + .assert_type(SetErrorType::InvalidForeignKey); + + // Remove the group membership and make sure it's gone + account + .registry_update_object( + ObjectType::Account, + account_id, + json!({ + Property::MemberGroupIds: { + group_id: false + } + }), + ) + .await; + let account_cache = test.server.account(account_id.document_id()).await.unwrap(); + assert!(account_cache.id_member_of.as_ref().is_empty()); // Create a mailing list - let list_id = store - .create_principal( - TestPrincipal { - name: "list".into(), - typ: Type::List, - emails: vec!["list@example.org".into()], - ..Default::default() - } - .into(), - None, - None, + let list_id = account + .registry_create_object(MailingList { + name: "newsletter".to_string(), + domain_id, + recipients: Map::new(vec!["jdoe@example.com".to_string()]), + ..Default::default() + }) + .await; + let list_cache = test + .server + .try_list(list_id.document_id()) + .await + .unwrap() + .unwrap(); + assert_eq!( + &list_cache.recipients, + &Arc::from(Box::from_iter(["jdoe@example.com".into()])) + ); + + // Update mailing list + account + .registry_update_object( + ObjectType::MailingList, + list_id, + json!({ + "recipients/sales@example.com": true + }), ) - .await - .unwrap() - .id; - assert!( - store - .update_principal(UpdatePrincipal::by_name("list").with_updates(vec![ - PrincipalUpdate::set( - PrincipalField::Members, - PrincipalValue::StringList(vec!["john".into(), "jane".into()]), - ), - PrincipalUpdate::set( - PrincipalField::ExternalMembers, - PrincipalValue::StringList(vec![ - "mike@other.org".into(), - "lucy@foobar.net".into() - ]), - ) - ])) - .await - .is_ok() - ); - - assert_list_members( - &store, - "list@example.org", - [ - "john@example.org", - "mike@other.org", - "lucy@foobar.net", - "jane@example.org", - ], - ) - .await; - - assert_eq!( - store - .query(QueryParams::name("list").with_return_member_of(true)) - .await - .unwrap() - .unwrap() - .into_test(), - TestPrincipal { - name: "list".into(), - id: list_id, - typ: Type::List, - emails: vec!["list@example.org".into()], - ..Default::default() - } - ); - assert_eq!( - store - .expn("list@example.org") - .await - .unwrap() - .into_iter() - .collect::>(), - [ - "john@example.org", - "mike@other.org", - "lucy@foobar.net", - "jane@example.org" - ] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - - // Create groups - store - .create_principal( - TestPrincipal { - name: "sales".into(), - description: Some("Sales Team".into()), - typ: Type::Group, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap(); - store - .create_principal( - TestPrincipal { - name: "support".into(), - description: Some("Support Team".into()), - typ: Type::Group, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap(); - - // Add John to the Sales and Support groups - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String("sales".into()), - ), - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String("support".into()), - ) - ])) - .await - .is_ok() - ); - let principal = store - .query(QueryParams::name("john").with_return_member_of(true)) + .await; + let list_cache = test + .server + .try_list(list_id.document_id()) .await .unwrap() .unwrap(); - let principal = store.map_principal(principal, &[]).await.unwrap(); assert_eq!( - principal.into_test().into_sorted(), - TestPrincipal { - id: john_id, - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["secret".into(), "$app$secret2".into()], - emails: vec!["john@example.org".into()], - member_of: vec!["sales".into(), "support".into()], - lists: vec!["list".into()], - ..Default::default() - } + &list_cache.recipients, + &Arc::from(Box::from_iter([ + "jdoe@example.com".into(), + "sales@example.com".into() + ])) ); - // Adding a non-existent user should fail - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String("accounting".into()), - ) - ])) - .await, - Err(manage::not_found("accounting")) - ); - - // Remove a member from a group - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::remove_item( - PrincipalField::MemberOf, - PrincipalValue::String("support".into()), - ) - ])) - .await - .is_ok() - ); - let principal = store - .query(QueryParams::name("john").with_return_member_of(true)) - .await - .unwrap() - .unwrap(); - let principal = store.map_principal(principal, &[]).await.unwrap(); - assert_eq!( - principal.into_test().into_sorted(), - TestPrincipal { - id: john_id, - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["secret".into(), "$app$secret2".into()], - emails: vec!["john@example.org".into()], - member_of: vec!["sales".into()], - lists: vec!["list".into()], - ..Default::default() - } - ); - - // Update multiple fields - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String("john.doe".into()) - ), - PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String("Johnny Doe".into()) - ), - PrincipalUpdate::set( - PrincipalField::Secrets, - PrincipalValue::StringList(vec!["12345".into()]) - ), - PrincipalUpdate::set(PrincipalField::Quota, PrincipalValue::Integer(1024)), - PrincipalUpdate::remove_item( - PrincipalField::Emails, - PrincipalValue::String("john@example.org".into()), - ), - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john.doe@example.org".into()), - ) - ])) - .await - .is_ok() - ); - - let principal = store - .query(QueryParams::name("john.doe").with_return_member_of(true)) - .await - .unwrap() - .unwrap(); - let principal = store.map_principal(principal, &[]).await.unwrap(); - assert_eq!( - principal.into_test().into_sorted(), - TestPrincipal { - id: john_id, - name: "john.doe".into(), - description: Some("Johnny Doe".into()), - secrets: vec!["12345".into()], - emails: vec!["john.doe@example.org".into()], - quota: 1024, - typ: Type::Individual, - member_of: vec!["sales".into()], - lists: vec!["list".into()], - ..Default::default() - } - ); - assert_eq!(store.get_principal_id("john").await.unwrap(), None); - assert_eq!( - store.rcpt("john@example.org").await.unwrap(), - RcptType::Invalid - ); - assert_eq!( - store.rcpt("john.doe@example.org").await.unwrap(), - RcptType::Mailbox - ); - - // Remove a member from a mailing list and then add it back - assert!( - store - .update_principal(UpdatePrincipal::by_name("list").with_updates(vec![ - PrincipalUpdate::remove_item( - PrincipalField::Members, - PrincipalValue::String("john.doe".into()), - ) - ])) - .await - .is_ok() - ); - assert_list_members( - &store, - "list@example.org", - ["jane@example.org", "mike@other.org", "lucy@foobar.net"], - ) - .await; - assert!( - store - .update_principal(UpdatePrincipal::by_name("list").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Members, - PrincipalValue::String("john.doe".into()), - ) - ])) - .await - .is_ok() - ); - assert_list_members( - &store, - "list@example.org", - [ - "john.doe@example.org", - "jane@example.org", - "mike@other.org", - "lucy@foobar.net", - ], - ) - .await; - - // Field validation - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john.doe").with_updates(vec![ - PrincipalUpdate::set(PrincipalField::Name, PrincipalValue::String("jane".into())), - ])) - .await, - Err(manage::err_exists(PrincipalField::Name, "jane")) - ); - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john.doe").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("jane@example.org".into()) - ), - ])) - .await, - Err(manage::err_exists( - PrincipalField::Emails, - "jane@example.org" - )) - ); - - // List accounts - assert_eq!( - store - .list_principals( - None, - None, - &[Type::Individual, Type::Group, Type::List], - true, - 0, - 0 - ) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["jane", "john.doe", "list", "sales", "support"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert_eq!( - store - .list_principals("john".into(), None, &[], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["john.doe"] - ); - assert_eq!( - store - .list_principals(None, None, &[Type::Individual], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["jane", "john.doe"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert_eq!( - store - .list_principals(None, None, &[Type::Group], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["sales", "support"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert_eq!( - store - .list_principals(None, None, &[Type::List], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["list"] - ); - assert_eq!( - store - .list_principals("example.org".into(), None, &[], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["example.org", "jane", "john.doe", "list"] - ); - assert_eq!( - store - .list_principals("johnny doe".into(), None, &[], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["john.doe"] - ); - - // Write records on John's and Jane's accounts - let mut document_id = u32::MAX; - for account_id in [john_id, jane_id] { - document_id = store - .assign_document_ids(u32::MAX, Collection::Principal, 1) - .await - .unwrap(); - store - .write( - BatchBuilder::new() - .with_account_id(account_id) - .with_collection(Collection::Email) - .with_document(document_id) - .set(ValueClass::Property(0), "hello".as_bytes()) - .build_all(), - ) - .await - .unwrap(); + // Verify RCPT expansion + for (address, expected) in [ + ( + "johndoe@example.com", + EmailCache::Account(account_id.document_id()), + ), + ( + "jdoe@example.com", + EmailCache::Account(account_id.document_id()), + ), + ( + "johndoe@beispiel.de", + EmailCache::Account(account_id.document_id()), + ), + ( + "jdoe@beispiel.de", + EmailCache::Account(account_id.document_id()), + ), + ( + "sales@example.com", + EmailCache::Account(group_id.document_id()), + ), + ( + "sales@beispiel.de", + EmailCache::Account(group_id.document_id()), + ), + ( + "newsletter@example.com", + EmailCache::MailingList(list_id.document_id()), + ), + ( + "newsletter@beispiel.de", + EmailCache::MailingList(list_id.document_id()), + ), + ] { assert_eq!( - store - .get_value::(ValueKey { - account_id, - collection: Collection::Email.into(), - document_id, - class: ValueClass::Property(0) - }) - .await - .unwrap(), - Some("hello".into()) + test.server.rcpt_id_from_email(address).await.unwrap(), + Some(expected), + "Unexpected result for address: {address}" ); } - - // Delete John's account and make sure his records are gone - let server = Server { - inner: Arc::new(Inner::default()), - core: Arc::new(Core { - storage: Storage { - data: store.clone(), - blob: store.clone().into(), - fts: store.clone().into(), - ..Default::default() - }, - ..Default::default() - }), - }; - store.delete_principal(QueryBy::Id(john_id)).await.unwrap(); - destroy_account_data(&server, john_id, true).await.unwrap(); - assert_eq!(store.get_principal_id("john.doe").await.unwrap(), None); assert_eq!( - store.email_to_id("john.doe@example.org").await.unwrap(), + test.server + .rcpt_id_from_email("unknown@example.com") + .await + .unwrap(), None ); assert_eq!( - store.rcpt("john.doe@example.org").await.unwrap(), - RcptType::Invalid + test.server + .rcpt_id_from_email("unknown@unknown.com") + .await + .unwrap(), + None + ); + + // Query tests + assert_eq!( + account + .registry_query( + ObjectType::Domain, + [(Property::Name, "example.com")], + [Property::Name] + ) + .await, + vec![domain_id] ); assert_eq!( - store - .list_principals( - None, - None, - &[Type::Individual, Type::Group, Type::List], - true, - 0, - 0 + account + .registry_query( + ObjectType::Account, + [ + (Property::Name, "johndoe"), + (Property::Type, AccountType::User.as_str()), + (Property::Text, "johnny") + ], + [Property::Name] ) + .await, + vec![account_id] + ); + + // Delete everything + assert_eq!( + account + .registry_destroy(ObjectType::MailingList, [list_id]) + .await + .destroyed_ids() + .collect::>(), + vec![list_id] + ); + assert_eq!( + account + .registry_destroy(ObjectType::Account, [group_id, account_id]) + .await + .destroyed_ids() + .collect::>(), + vec![group_id, account_id] + ); + assert_eq!( + account + .registry_destroy(ObjectType::Domain, [domain_id]) + .await + .destroyed_ids() + .collect::>(), + vec![domain_id] + ); + assert!( + test.server + .try_list(list_id.document_id()) .await .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["jane", "list", "sales", "support"] - .into_iter() - .map(|s| s.into()) - .collect::>() + .is_none() ); - assert!(!account_has_emails(&store, john_id).await); - assert_eq!( - store - .get_value::(ValueKey { - account_id: john_id, - collection: Collection::Email.into(), - document_id: 0, - class: ValueClass::Property(0) - }) + assert!( + test.server + .try_account(account_id.document_id()) .await - .unwrap(), - None + .unwrap() + .is_none() ); - - // Make sure Jane's records are still there - assert_eq!(store.get_principal_id("jane").await.unwrap(), Some(jane_id)); - assert_eq!( - store.email_to_id("jane@example.org").await.unwrap(), - Some(jane_id) - ); - assert_eq!( - store.rcpt("jane@example.org").await.unwrap(), - RcptType::Mailbox - ); - assert!(account_has_emails(&store, jane_id).await); - assert_eq!( - store - .get_value::(ValueKey { - account_id: jane_id, - collection: Collection::Email.into(), - document_id, - class: ValueClass::Property(0) - }) + assert!( + test.server + .try_account(group_id.document_id()) .await - .unwrap(), - Some("hello".into()) + .unwrap() + .is_none() ); - - // Clean up - destroy_account_data(&server, jane_id, true).await.unwrap(); - for principal_name in ["jane", "list", "sales", "support", "example.org"] { - store - .delete_principal(QueryBy::Name(principal_name)) - .await - .unwrap(); - } - store_assert_is_empty(&store, store.clone().into(), true).await;*/ + assert!(test.server.domain("example.com").await.unwrap().is_none()); } diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 31a6ebdd..b5c6bd36 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -4,7 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +pub mod authentication; pub mod directory; + use crate::utils::server::TestServerBuilder; #[tokio::test(flavor = "multi_thread")] @@ -26,8 +28,9 @@ pub async fn system_tests() { ) .await; test.account("admin") - .assign_role_to_account(admin_id, "superuser") + .assign_roles_to_account(admin_id, &["user", "superuser"]) .await; - directory::test(&test).await; + //directory::test(&test).await; + authentication::test(&test).await; } diff --git a/tests/src/utils/account.rs b/tests/src/utils/account.rs index b690fa1c..94a5b6f9 100644 --- a/tests/src/utils/account.rs +++ b/tests/src/utils/account.rs @@ -10,9 +10,12 @@ use jmap_client::client::{Client, Credentials}; use registry::{ schema::{ prelude::{ObjectType, Property}, - structs::{self, Credential, Domain, EmailAlias, PasswordCredential, UserAccount}, + structs::{ + self, Credential, CustomRoles, Domain, EmailAlias, PasswordCredential, Roles, + UserAccount, + }, }, - types::list::List, + types::{list::List, map::Map}, }; use serde_json::json; use std::time::Duration; @@ -144,9 +147,8 @@ impl Account { [(Property::Name, name)], Vec::<&str>::new(), ) - .await - .object_ids() - .collect::>(); + .await; + match ids.len() { 0 => self.create_domain(name).await, 1 => ids[0], @@ -163,30 +165,32 @@ impl Account { .await } - pub async fn assign_role_to_account(&self, account_id: Id, name: &str) { - let role_id = self - .registry_query( - ObjectType::Role, - [(Property::Description, name)], - Vec::<&str>::new(), - ) - .await - .object_ids() - .next() - .unwrap_or_else(|| panic!("Role {name} not found")); + pub async fn assign_roles_to_account(&self, account_id: Id, names: &[&str]) { + let mut role_ids = Vec::new(); + for name in names { + let role_id = *self + .registry_query( + ObjectType::Role, + [(Property::Description, *name)], + Vec::<&str>::new(), + ) + .await + .first() + .unwrap_or_else(|| panic!("Role {name} not found")); + role_ids.push(role_id); + } self.registry_update( ObjectType::Account, [( account_id, json!({ - "roleIds": { - role_id: true - } + Property::Roles: Roles::Custom(CustomRoles { role_ids: Map::new(role_ids) }) }), )], ) - .await; + .await + .updated_id(account_id); } pub async fn client_owned(&self) -> Client { diff --git a/tests/src/utils/jmap.rs b/tests/src/utils/jmap.rs index ee7d7af7..8189d67c 100644 --- a/tests/src/utils/jmap.rs +++ b/tests/src/utils/jmap.rs @@ -7,6 +7,9 @@ use crate::utils::account::Account; use base64::{Engine, engine::general_purpose}; use hyper::header; +use jmap_proto::error::set::SetErrorType; +use registry::types::error::ValidationError; +use registry::types::id::ObjectId; use serde_json::{Value, json}; use std::{fmt::Display, str::FromStr, time::Duration}; use types::id::Id; @@ -427,6 +430,10 @@ impl JmapResponse { .unwrap_or_else(|| panic!("Missing updated item {id}: {self:?}")) } + pub fn updated_id(&self, id: Id) -> &Value { + self.updated(&id.to_string()) + } + pub fn not_updated(&self, id: &str) -> &Value { self.0 .pointer(&format!("/methodResponses/0/1/notUpdated/{id}")) @@ -491,6 +498,12 @@ impl JmapResponse { .map(|v| v.as_str().unwrap()) } + pub fn destroyed_ids(&self) -> impl Iterator { + self.destroyed().map(move |id| { + Id::from_str(id).unwrap_or_else(|_| panic!("Invalid id {id} in response: {self:?}")) + }) + } + pub fn not_destroyed(&self, id: &str) -> &Value { self.0 .pointer(&format!("/methodResponses/0/1/notDestroyed/{id}")) @@ -536,6 +549,68 @@ impl JmapResponse { } } +#[derive(Debug, PartialEq, Eq, serde::Deserialize)] +pub struct JmapSetError { + #[serde(rename = "type")] + pub type_: SetErrorType, + + #[serde(default)] + pub description: Option, + + #[serde(default)] + pub properties: Option>, + + #[serde(rename = "existingId")] + #[serde(default)] + pub existing_id: Option, + + #[serde(rename = "objectId")] + #[serde(default)] + pub object_id: Option, + + #[serde(default)] + #[serde(rename = "linkedObjects")] + pub linked_objects: Vec, + + #[serde(default)] + #[serde(rename = "validationErrors")] + pub validation_errors: Vec, +} + +impl JmapSetError { + pub fn assert_type(&self, expected: SetErrorType) -> &Self { + if self.type_ != expected { + panic!("Expected error type {expected:?} but got {self:?}"); + } + self + } + + pub fn assert_description_contains(&self, expected: &str) -> &Self { + if let Some(description) = &self.description { + if !description.contains(expected) { + panic!("Expected error description to contain {expected} but got {description}"); + } + } else { + panic!("Expected error description to contain {expected} but got no description"); + } + self + } + + pub fn assert_properties(&self, expected: &[&str]) -> &Self { + let properties = self.properties.as_ref().unwrap_or_else(|| { + panic!("Expected error to have properties {expected:?} but got no properties: {self:?}") + }); + for expected in expected { + if !properties.contains(&expected.to_string()) { + panic!( + "Expected error to have property {expected} but got properties {properties:?}: {self:?}" + ); + } + } + self + } +} + pub trait JmapUtils { fn id(&self) -> &str { self.text_field("id") diff --git a/tests/src/utils/registry.rs b/tests/src/utils/registry.rs index b859aecf..de2e8f45 100644 --- a/tests/src/utils/registry.rs +++ b/tests/src/utils/registry.rs @@ -4,7 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::utils::{account::Account, jmap::JmapResponse}; +use crate::utils::{ + account::Account, + jmap::{JmapResponse, JmapSetError}, +}; use registry::{ schema::prelude::ObjectType, types::{EnumImpl, ObjectImpl}, @@ -27,9 +30,7 @@ impl Account { items.into_iter().map(|item| { let mut item = serde_json::to_value(item).expect("Failed to serialize item to JSON"); - item.as_object_mut() - .unwrap() - .retain(|k, _| !["createdAt", "credentialId"].contains(&k.as_str())); + remove_server_set_props(&mut item); item }), Vec::<(&str, &str)>::new(), @@ -53,7 +54,7 @@ impl Account { object: ObjectType, filter: impl IntoIterator)>, sort_by: impl IntoIterator, - ) -> JmapResponse { + ) -> Vec { let name = object.as_str(); self.jmap_query( @@ -63,6 +64,8 @@ impl Account { Vec::<(&str, &str)>::new(), ) .await + .object_ids() + .collect() } pub async fn registry_destroy( @@ -106,6 +109,35 @@ impl Account { pub async fn registry_create_object(&self, item: T) -> Id { self.registry_create([item]).await.created_id(0) } + + pub async fn registry_create_object_expect_err(&self, item: T) -> JmapSetError { + let v = self + .registry_create([item]) + .await + .not_created(0) + .to_string(); + serde_json::from_str(&v).expect("Failed to deserialize set error") + } + + pub async fn registry_update_object(&self, object: ObjectType, id: Id, item: Value) { + self.registry_update(object, [(id, item)]) + .await + .updated_id(id); + } + + pub async fn registry_update_object_expect_err( + &self, + object: ObjectType, + id: Id, + item: Value, + ) -> JmapSetError { + let v = self + .registry_update(object, [(id, item)]) + .await + .not_updated(&id.to_string()) + .to_string(); + serde_json::from_str(&v).expect("Failed to deserialize set error") + } } impl JmapResponse { @@ -128,3 +160,18 @@ impl UnwrapRegistryId for RegistryWriteResult { } } } + +fn remove_server_set_props(value: &mut serde_json::Value) { + if let Value::Object(obj) = value { + let is_app_pass = obj + .get("@type") + .and_then(|v| v.as_str()) + .is_some_and(|t| ["AppPassword", "ApiKey"].contains(&t)); + obj.retain(|k, _| { + !(["createdAt", "credentialId"].contains(&k.as_str()) || (is_app_pass && k == "secret")) + }); + for v in obj.values_mut() { + remove_server_set_props(v); + } + } +} diff --git a/tests/src/utils/server.rs b/tests/src/utils/server.rs index 9ebdcc63..4d938fa2 100644 --- a/tests/src/utils/server.rs +++ b/tests/src/utils/server.rs @@ -160,34 +160,28 @@ impl TestServerBuilder { let level = std::env::var("LOG") .map(|log| TracingLevel::parse(&log).expect("Invalid log level")) .ok(); - self.bootstrap - .registry - .write(RegistryWrite::insert( - &Tracer::Stdout(TracerStdout { - enable: level.is_some(), - level: level.unwrap_or(TracingLevel::Info), - ansi: true, - multiline: false, - events: Map::new( - EventType::variants() - .iter() - .filter(|ev| { - let ev = ev.as_str(); - ev.starts_with("network.") - || ev == "telemetry.webhook-error" - || ev == "http.request-body" - }) - .copied() - .collect(), - ), - events_policy: EventPolicy::Exclude, - ..Default::default() - }) - .into(), - )) - .await - .unwrap() - .unwrap_id(trc::location!()); + + self.insert_object(Tracer::Stdout(TracerStdout { + enable: level.is_some(), + level: level.unwrap_or(TracingLevel::Info), + ansi: true, + multiline: false, + events: Map::new( + EventType::variants() + .iter() + .filter(|ev| { + let ev = ev.as_str(); + ev.starts_with("network.") + || ev == "telemetry.webhook-error" + || ev == "http.request-body" + }) + .copied() + .collect(), + ), + events_policy: EventPolicy::Exclude, + ..Default::default() + })) + .await; // Start listeners let mut servers = Listeners::parse(&mut self.bootstrap).await;