From 56064d7f62b48d2a99af2bcfd13c417f2cc633d3 Mon Sep 17 00:00:00 2001 From: mdecimus <11444311+mdecimus@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:25:16 +0100 Subject: [PATCH] Registry testing - part 6 --- crates/common/src/auth/access_token.rs | 28 +- crates/common/src/auth/permissions.rs | 16 +- crates/common/src/cache/directory.rs | 3 +- crates/common/src/cache/invalidate.rs | 2 +- crates/http/src/auth/oauth/auth.rs | 2 - crates/jmap/src/registry/get.rs | 35 +- crates/jmap/src/registry/mapping/account.rs | 2 +- crates/jmap/src/registry/mapping/principal.rs | 1 + crates/jmap/src/registry/set.rs | 3 + tests/src/jmap/auth/permissions.rs | 908 ------------------ tests/src/store/registry.rs | 4 +- tests/src/system/authorization.rs | 448 +++++++++ tests/src/system/mod.rs | 5 +- tests/src/system/tenant.rs | 678 +++++++++++++ tests/src/utils/registry.rs | 31 +- 15 files changed, 1212 insertions(+), 954 deletions(-) delete mode 100644 tests/src/jmap/auth/permissions.rs create mode 100644 tests/src/system/authorization.rs create mode 100644 tests/src/system/tenant.rs diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index e78415dc..af669a71 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -17,7 +17,7 @@ use ahash::AHasher; use registry::{ schema::{ enums::Permission, - structs::{self, Account, Roles}, + structs::{self, Account, Roles, UserRoles}, }, types::EnumImpl, }; @@ -46,10 +46,18 @@ impl Server { let permissions = self .effective_permissions( &account.permissions, - account - .roles - .role_ids() - .unwrap_or(self.core.network.security.default_role_ids_user.as_slice()), + match &account.roles { + UserRoles::User => { + self.core.network.security.default_role_ids_user.as_slice() + } + UserRoles::TenantAdmin => self + .core + .network + .security + .default_role_ids_tenant + .as_slice(), + UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(), + }, tenant_id, ) .await?; @@ -731,8 +739,14 @@ fn hash_account(account: &Account) -> u64 { Account::User(account) => { account.member_tenant_id.hash(&mut s); match &account.roles { - Roles::Default => {} - Roles::Custom(custom_roles) => { + UserRoles::User => { + 0u8.hash(&mut s); + } + UserRoles::TenantAdmin => { + 1u8.hash(&mut s); + } + UserRoles::Custom(custom_roles) => { + 2u8.hash(&mut s); custom_roles.role_ids.as_slice().hash(&mut s); } } diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index d68d63a6..2c96d0d8 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -12,7 +12,7 @@ use ahash::AHashSet; use registry::{ schema::{ enums::Permission, - structs::{self, Account, PermissionsList}, + structs::{self, Account, PermissionsList, UserRoles}, }, types::EnumImpl, }; @@ -112,10 +112,16 @@ impl Server { let (permissions, role_ids, tenant_id) = match account { Account::User(account) => ( &account.permissions, - account - .roles - .role_ids() - .unwrap_or(self.core.network.security.default_role_ids_user.as_slice()), + match &account.roles { + UserRoles::User => self.core.network.security.default_role_ids_user.as_slice(), + UserRoles::TenantAdmin => self + .core + .network + .security + .default_role_ids_tenant + .as_slice(), + UserRoles::Custom(custom_roles) => custom_roles.role_ids.as_slice(), + }, account.member_tenant_id.map(|t| t.document_id()), ), Account::Group(account) => ( diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index b558cc94..21815f72 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -12,6 +12,7 @@ use registry::{ prelude::{Object, ObjectType}, structs::{ Account, Credential, EmailAlias, GroupAccount, PasswordCredential, Roles, UserAccount, + UserRoles, }, }, types::{datetime::UTCDateTime, id::ObjectId, list::List}, @@ -180,7 +181,7 @@ impl Server { description: account.description, member_group_ids: member_group_ids.into(), member_tenant_id: domain.id_tenant.map(Id::from), - roles: Roles::Default, + roles: UserRoles::User, credentials: List::from_iter([Credential::Password(PasswordCredential { secret: account.secret.unwrap_or_default(), ..Default::default() diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index 80a7035e..ae616aee 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -197,10 +197,10 @@ 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(()); } + let c = println!("Invalidating caches for changes: {:?}", changes); // Invalidate objects linking roles let mut role_ids = changes diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index 27a9fb40..b8231c0d 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -370,8 +370,6 @@ impl OAuthApiHandler for Server { ) .await?; - let c = println!("Expires in: {}", self.core.oauth.oauth_expiry_user_code); - // Insert user code self.in_memory_store() .key_set( diff --git a/crates/jmap/src/registry/get.rs b/crates/jmap/src/registry/get.rs index d522d1c9..0a64c3de 100644 --- a/crates/jmap/src/registry/get.rs +++ b/crates/jmap/src/registry/get.rs @@ -260,12 +260,20 @@ impl RegistryGet for Server { || get.properties.contains(&Property::DnsZoneFile) => { let todo = "domain dns zone file"; - todo!() } _ => {} } - get.insert(id, object.into_value()); + let mut object = object.into_value(); + if !extra_properties.is_empty() + && let JmapValue::Object(obj) = &mut object + { + for (key, value) in extra_properties { + obj.insert_unchecked(key, value); + } + } + + get.insert(id, object); } Ok(get.into_response()) @@ -300,30 +308,9 @@ impl RegistryGetResponse<'_> { pub fn insert(&mut self, id: Id, mut object: JmapValue<'static>) { let object_map = object.as_object_mut().unwrap(); - if self.is_tenant_filtered - && let Some(tenant_id) = self.access_token.tenant_id() - { - let expected_value = JmapValue::Element(RegistryValue::Id(Id::from(tenant_id))); - for (key, value) in object_map.iter() { - if matches!(key, Key::Property(Property::MemberTenantId)) - && (value != &expected_value - || value - .as_array() - .is_none_or(|arr| !arr.contains(&expected_value))) - { - self.not_found(id); - return; - } - } + if self.is_tenant_filtered && self.access_token.tenant_id().is_some() { object_map.remove(&Key::Property(Property::MemberTenantId)); } else if self.is_account_filtered { - let expected_value = JmapValue::Element(RegistryValue::Id(self.account_id.into())); - for (key, value) in object_map.iter() { - if matches!(key, Key::Property(Property::AccountId)) && value != &expected_value { - self.not_found(id); - return; - } - } object_map.remove(&Key::Property(Property::AccountId)); } diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs index 68e2da28..db357770 100644 --- a/crates/jmap/src/registry/mapping/account.rs +++ b/crates/jmap/src/registry/mapping/account.rs @@ -796,7 +796,7 @@ pub(crate) async fn credential_query( Ok(response) } -fn validate_credential_permissions( +pub(crate) fn validate_credential_permissions( access_token: &AccessToken, credential: &SecondaryCredential, ) -> Result<(), SetError> { diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs index a7b13d2e..b9373fe3 100644 --- a/crates/jmap/src/registry/mapping/principal.rs +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -322,6 +322,7 @@ pub(crate) async fn validate_tenant_quota( (ObjectType::OAuthClient, None, "OAuth clients") } TenantStorageQuota::MaxDkimKeys => (ObjectType::DkimSignature, None, "DKIM keys"), + TenantStorageQuota::MaxDnsServers => (ObjectType::DnsServer, None, "DNS servers"), TenantStorageQuota::MaxDiskQuota => unreachable!(), }; let query = RegistryQuery::new(object_type).with_tenant(tenant_id.into()); diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 064a8496..0fa7e1bb 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -420,6 +420,9 @@ impl RegistrySet for Server { ObjectInner::OAuthClient(_) if is_create => { validate_tenant_quota(&set, TenantStorageQuota::MaxOauthClients).await? } + ObjectInner::DnsServer(_) if is_create => { + validate_tenant_quota(&set, TenantStorageQuota::MaxDnsServers).await? + } _ => Ok(ObjectResponse::default()), }; diff --git a/tests/src/jmap/auth/permissions.rs b/tests/src/jmap/auth/permissions.rs deleted file mode 100644 index 6993ec64..00000000 --- a/tests/src/jmap/auth/permissions.rs +++ /dev/null @@ -1,908 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use crate::{ - directory::internal::TestInternalDirectory, - jmap::{JMAPTest, ManagementApi, server::List}, -}; -use ahash::AHashSet; -use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; -use std::sync::Arc; - -pub async fn test(params: &JMAPTest) { - println!("Running permissions tests..."); - let server = params.server.clone(); - - // Disable spam filtering to avoid adding extra headers - let old_core = params.server.core.clone(); - let mut new_core = old_core.as_ref().clone(); - new_core.spam.enabled = false; - new_core.smtp.session.data.add_delivered_to = false; - params.server.inner.shared_core.store(Arc::new(new_core)); - - // Remove unlimited requests permission - for &account in params.accounts.keys() { - params - .server - .store() - .remove_permissions(account, [Permission::UnlimitedRequests]) - .await; - } - - // Prepare management API - let api = ManagementApi::new(8899, "admin", "secret"); - - // Create a user with the default 'user' role - let account_id = api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Individual) - .with_field(PrincipalField::Name, "role_player") - .with_field(PrincipalField::Roles, vec!["user".to_string()]) - .with_field( - PrincipalField::DisabledPermissions, - vec![Permission::Pop3Dele.name().to_string()], - ), - ) - .await - .unwrap() - .unwrap_data(); - let revision = server - .get_access_token(account_id) - .await - .unwrap() - .validate_permissions( - Permission::all().filter(|p| p.is_user_permission() && *p != Permission::Pop3Dele), - ) - .revision; - - // Create multiple roles - for (role, permissions, parent_role) in &[ - ( - "pop3_user", - vec![Permission::Pop3Authenticate, Permission::Pop3List], - vec![], - ), - ( - "imap_user", - vec![Permission::ImapAuthenticate, Permission::ImapList], - vec![], - ), - ( - "jmap_user", - vec![ - Permission::JmapEmailQuery, - Permission::AuthenticateOauth, - Permission::ManageEncryption, - ], - vec![], - ), - ( - "email_user", - vec![Permission::EmailSend, Permission::EmailReceive], - vec!["pop3_user", "imap_user", "jmap_user"], - ), - ] { - api.post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Role) - .with_field(PrincipalField::Name, role.to_string()) - .with_field( - PrincipalField::EnabledPermissions, - permissions - .iter() - .map(|p| p.name().to_string()) - .collect::>(), - ) - .with_field( - PrincipalField::Roles, - parent_role - .iter() - .map(|r| r.to_string()) - .collect::>(), - ), - ) - .await - .unwrap() - .unwrap_data(); - } - - // Update email_user role - api.patch::<()>( - "/api/principal/email_user", - &vec![PrincipalUpdate::add_item( - PrincipalField::DisabledPermissions, - PrincipalValue::String(Permission::ManageEncryption.name().to_string()), - )], - ) - .await - .unwrap() - .unwrap_data(); - - // Update the user role to the nested 'email_user' role - api.patch::<()>( - "/api/principal/role_player", - &vec![PrincipalUpdate::set( - PrincipalField::Roles, - PrincipalValue::StringList(vec!["email_user".to_string()]), - )], - ) - .await - .unwrap() - .unwrap_data(); - assert_ne!( - server - .get_access_token(account_id) - .await - .unwrap() - .validate_permissions([ - Permission::EmailSend, - Permission::EmailReceive, - Permission::JmapEmailQuery, - Permission::AuthenticateOauth, - Permission::ImapAuthenticate, - Permission::ImapList, - Permission::Pop3Authenticate, - Permission::Pop3List, - ]) - .revision, - revision - ); - - // Query all principals - api.get::>("/api/principal") - .await - .unwrap() - .unwrap_data() - .assert_count(12) - .assert_exists( - "admin", - Type::Individual, - [ - (PrincipalField::Roles, &["admin"][..]), - (PrincipalField::Members, &[][..]), - (PrincipalField::EnabledPermissions, &[][..]), - (PrincipalField::DisabledPermissions, &[][..]), - ], - ) - .assert_exists( - "role_player", - Type::Individual, - [ - (PrincipalField::Roles, &["email_user"][..]), - (PrincipalField::Members, &[][..]), - (PrincipalField::EnabledPermissions, &[][..]), - ( - PrincipalField::DisabledPermissions, - &[Permission::Pop3Dele.name()][..], - ), - ], - ) - .assert_exists( - "email_user", - Type::Role, - [ - ( - PrincipalField::Roles, - &["pop3_user", "imap_user", "jmap_user"][..], - ), - (PrincipalField::Members, &["role_player"][..]), - ( - PrincipalField::EnabledPermissions, - &[ - Permission::EmailReceive.name(), - Permission::EmailSend.name(), - ][..], - ), - ( - PrincipalField::DisabledPermissions, - &[Permission::ManageEncryption.name()][..], - ), - ], - ) - .assert_exists( - "pop3_user", - Type::Role, - [ - (PrincipalField::Roles, &[][..]), - (PrincipalField::Members, &["email_user"][..]), - ( - PrincipalField::EnabledPermissions, - &[ - Permission::Pop3Authenticate.name(), - Permission::Pop3List.name(), - ][..], - ), - (PrincipalField::DisabledPermissions, &[][..]), - ], - ) - .assert_exists( - "imap_user", - Type::Role, - [ - (PrincipalField::Roles, &[][..]), - (PrincipalField::Members, &["email_user"][..]), - ( - PrincipalField::EnabledPermissions, - &[ - Permission::ImapAuthenticate.name(), - Permission::ImapList.name(), - ][..], - ), - (PrincipalField::DisabledPermissions, &[][..]), - ], - ) - .assert_exists( - "jmap_user", - Type::Role, - [ - (PrincipalField::Roles, &[][..]), - (PrincipalField::Members, &["email_user"][..]), - ( - PrincipalField::EnabledPermissions, - &[ - Permission::JmapEmailQuery.name(), - Permission::AuthenticateOauth.name(), - Permission::ManageEncryption.name(), - ][..], - ), - (PrincipalField::DisabledPermissions, &[][..]), - ], - ); - - // Create new tenants - let tenant_id = api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Tenant) - .with_field(PrincipalField::Name, "foobar") - .with_field( - PrincipalField::Roles, - vec!["tenant-admin".to_string(), "user".to_string()], - ) - .with_field( - PrincipalField::Quota, - PrincipalValue::IntegerList(vec![TENANT_QUOTA, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]), - ), - ) - .await - .unwrap() - .unwrap_data(); - let other_tenant_id = api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Tenant) - .with_field(PrincipalField::Name, "xanadu") - .with_field( - PrincipalField::Roles, - vec!["tenant-admin".to_string(), "user".to_string()], - ), - ) - .await - .unwrap() - .unwrap_data(); - - // Creating a tenant without a valid domain should fail - api.post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Individual) - .with_field(PrincipalField::Name, "admin-foobar") - .with_field(PrincipalField::Roles, vec!["tenant-admin".to_string()]) - .with_field( - PrincipalField::Secrets, - PrincipalValue::String("mytenantpass".to_string()), - ) - .with_field( - PrincipalField::Tenant, - PrincipalValue::String("foobar".to_string()), - ), - ) - .await - .unwrap() - .expect_error("Principal name must include a valid domain assigned to the tenant"); - - // Create domain for the tenant and one outside the tenant - api.post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Domain) - .with_field(PrincipalField::Name, "foobar.org") - .with_field( - PrincipalField::Tenant, - PrincipalValue::String("foobar".to_string()), - ), - ) - .await - .unwrap() - .unwrap_data(); - api.post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Domain).with_field(PrincipalField::Name, "example.org"), - ) - .await - .unwrap() - .unwrap_data(); - - // Create tenant admin - let tenant_admin_id = api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Individual) - .with_field(PrincipalField::Name, "admin@foobar.org") - .with_field(PrincipalField::Roles, vec!["tenant-admin".to_string()]) - .with_field( - PrincipalField::Secrets, - PrincipalValue::String("mytenantpass".to_string()), - ) - .with_field( - PrincipalField::Tenant, - PrincipalValue::String("foobar".to_string()), - ), - ) - .await - .unwrap() - .unwrap_data(); - - // Verify permissions - server - .get_access_token(tenant_admin_id) - .await - .unwrap() - .validate_permissions(Permission::all().filter(|p| p.is_tenant_admin_permission())) - .validate_tenant(tenant_id, TENANT_QUOTA); - - // Prepare tenant admin API - let tenant_api = ManagementApi::new(8899, "admin@foobar.org", "mytenantpass"); - - // Tenant should not be able to create other tenants or modify its tenant id - tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Tenant) - .with_field(PrincipalField::Name, "subfoobar"), - ) - .await - .unwrap() - .expect_request_error("Forbidden"); - tenant_api - .patch::<()>( - "/api/principal/foobar", - &vec![PrincipalUpdate::set( - PrincipalField::Tenant, - PrincipalValue::String("subfoobar".to_string()), - )], - ) - .await - .unwrap() - .expect_request_error("Forbidden"); - tenant_api - .get::<()>("/api/principal/foobar") - .await - .unwrap() - .expect_request_error("Forbidden"); - tenant_api - .get::<()>("/api/principal?type=tenant") - .await - .unwrap() - .expect_request_error("Forbidden"); - - // Create a second domain for the tenant - tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Domain) - .with_field(PrincipalField::Name, "foobar.com"), - ) - .await - .unwrap() - .unwrap_data(); - - // Creating a third domain should be limited by quota - tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Domain) - .with_field(PrincipalField::Name, "foobar.net"), - ) - .await - .unwrap() - .expect_request_error("Tenant quota exceeded"); - - // Creating a tenant user without a valid domain or with a domain outside the tenant should fail - for user in ["mytenantuser", "john@example.org"] { - tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Individual) - .with_field(PrincipalField::Name, user.to_string()) - .with_field(PrincipalField::Roles, vec!["tenant-admin".to_string()]), - ) - .await - .unwrap() - .expect_error("Principal name must include a valid domain assigned to the tenant"); - } - - // Create an account - let tenant_user_id = tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Individual) - .with_field(PrincipalField::Name, "john@foobar.org") - .with_field( - PrincipalField::Roles, - vec!["tenant-admin".to_string(), "user".to_string()], - ) - .with_field( - PrincipalField::Secrets, - PrincipalValue::String("tenantpass".to_string()), - ) - .with_field( - PrincipalField::Tenant, - PrincipalValue::String("xanadu".to_string()), - ), - ) - .await - .unwrap() - .unwrap_data(); - - // Although super user privileges were used and a different tenant name was provided, this should be ignored - server - .get_access_token(tenant_user_id) - .await - .unwrap() - .validate_permissions( - Permission::all().filter(|p| p.is_tenant_admin_permission() || p.is_user_permission()), - ) - .validate_tenant(tenant_id, TENANT_QUOTA); - - // Create a second account should be limited by quota - tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Individual) - .with_field(PrincipalField::Name, "jane@foobar.org") - .with_field(PrincipalField::Roles, vec!["tenant-admin".to_string()]), - ) - .await - .unwrap() - .expect_request_error("Tenant quota exceeded"); - - // Create an tenant role - tenant_api - .post::( - "/api/principal", - &PrincipalSet::new(u32::MAX, Type::Role) - .with_field(PrincipalField::Name, "no-mail-for-you@foobar.com") - .with_field( - PrincipalField::DisabledPermissions, - vec![Permission::EmailReceive.name().to_string()], - ), - ) - .await - .unwrap() - .unwrap_data(); - - // Assigning a role that does not belong to the tenant should fail - tenant_api - .patch::<()>( - "/api/principal/john@foobar.org", - &vec![PrincipalUpdate::add_item( - PrincipalField::Roles, - PrincipalValue::String("imap_user".to_string()), - )], - ) - .await - .unwrap() - .expect_error("notFound"); - - // Add tenant defined role - tenant_api - .patch::<()>( - "/api/principal/john@foobar.org", - &vec![PrincipalUpdate::add_item( - PrincipalField::Roles, - PrincipalValue::String("no-mail-for-you@foobar.com".to_string()), - )], - ) - .await - .unwrap() - .unwrap_data(); - - // Check updated permissions - server - .get_access_token(tenant_user_id) - .await - .unwrap() - .validate_permissions(Permission::all().filter(|p| { - (p.is_tenant_admin_permission() || p.is_user_permission()) - && *p != Permission::EmailReceive - })); - - // Changing the tenant of a user should fail - tenant_api - .patch::<()>( - "/api/principal/john@foobar.org", - &vec![PrincipalUpdate::set( - PrincipalField::Tenant, - PrincipalValue::String("xanadu".to_string()), - )], - ) - .await - .unwrap() - .expect_request_error("Forbidden"); - - // Renaming a tenant account without a valid domain should fail - for user in ["john", "john@example.org"] { - tenant_api - .patch::<()>( - "/api/principal/john@foobar.org", - &vec![PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String(user.to_string()), - )], - ) - .await - .unwrap() - .expect_error("Principal name must include a valid domain assigned to the tenant"); - } - - // Rename the tenant account and add an email address - tenant_api - .patch::<()>( - "/api/principal/john@foobar.org", - &vec![ - PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String("john.doe@foobar.org".to_string()), - ), - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john@foobar.org".to_string()), - ), - ], - ) - .await - .unwrap() - .unwrap_data(); - - // Tenants should only see their own principals - tenant_api - .get::>("/api/principal?types=individual,group,role,list") - .await - .unwrap() - .unwrap_data() - .assert_count(3) - .assert_exists( - "admin@foobar.org", - Type::Individual, - [ - (PrincipalField::Roles, &["tenant-admin"][..]), - (PrincipalField::Members, &[][..]), - (PrincipalField::EnabledPermissions, &[][..]), - (PrincipalField::DisabledPermissions, &[][..]), - ], - ) - .assert_exists( - "john.doe@foobar.org", - Type::Individual, - [ - ( - PrincipalField::Roles, - &["tenant-admin", "no-mail-for-you@foobar.com", "user"][..], - ), - (PrincipalField::Members, &[][..]), - (PrincipalField::EnabledPermissions, &[][..]), - (PrincipalField::DisabledPermissions, &[][..]), - ], - ) - .assert_exists( - "no-mail-for-you@foobar.com", - Type::Role, - [ - (PrincipalField::Roles, &[][..]), - (PrincipalField::Members, &["john.doe@foobar.org"][..]), - (PrincipalField::EnabledPermissions, &[][..]), - ( - PrincipalField::DisabledPermissions, - &[Permission::EmailReceive.name()][..], - ), - ], - ); - - // John should not be allowed to receive email - let (message_blob, _) = server - .put_temporary_blob(tenant_user_id, TEST_MESSAGE.as_bytes(), 60) - .await - .unwrap(); - assert_eq!( - server - .deliver_message(IngestMessage { - sender_address: "bill@foobar.org".to_string(), - sender_authenticated: true, - recipients: vec![IngestRecipient { - address: "john@foobar.org".to_string(), - is_spam: false - }], - message_blob: message_blob.clone(), - message_size: TEST_MESSAGE.len() as u64, - session_id: 0, - }) - .await - .status, - vec![LocalDeliveryStatus::PermanentFailure { - code: [5, 5, 0], - reason: "This account is not authorized to receive email.".into() - }] - ); - - // Remove the restriction - tenant_api - .patch::<()>( - "/api/principal/john.doe@foobar.org", - &vec![PrincipalUpdate::remove_item( - PrincipalField::Roles, - PrincipalValue::String("no-mail-for-you@foobar.com".to_string()), - )], - ) - .await - .unwrap() - .unwrap_data(); - server - .get_access_token(tenant_user_id) - .await - .unwrap() - .validate_permissions( - Permission::all().filter(|p| p.is_tenant_admin_permission() || p.is_user_permission()), - ); - - // Delivery should now succeed - assert_eq!( - server - .deliver_message(IngestMessage { - sender_address: "bill@foobar.org".to_string(), - sender_authenticated: true, - recipients: vec![IngestRecipient { - address: "john@foobar.org".to_string(), - is_spam: false - }], - message_blob: message_blob.clone(), - message_size: TEST_MESSAGE.len() as u64, - session_id: 0, - }) - .await - .status, - vec![LocalDeliveryStatus::Success] - ); - - // Quota for the tenant and user should be updated - const EXTRA_BYTES: i64 = 19; // Storage overhead - assert_eq!( - server.get_used_quota(tenant_id).await.unwrap(), - TEST_MESSAGE.len() as i64 + EXTRA_BYTES - ); - assert_eq!( - server.get_used_quota(tenant_user_id).await.unwrap(), - TEST_MESSAGE.len() as i64 + EXTRA_BYTES - ); - - // Next delivery should fail due to tenant quota - assert_eq!( - server - .deliver_message(IngestMessage { - sender_address: "bill@foobar.org".to_string(), - sender_authenticated: true, - recipients: vec![IngestRecipient { - address: "john@foobar.org".to_string(), - is_spam: false - }], - message_blob, - message_size: TEST_MESSAGE.len() as u64, - session_id: 0, - }) - .await - .status, - vec![LocalDeliveryStatus::TemporaryFailure { - reason: "Organization over quota.".into() - }] - ); - - // Moving a user to another tenant should move its quota too - api.patch::<()>( - "/api/principal/john.doe@foobar.org", - &vec![PrincipalUpdate::set( - PrincipalField::Tenant, - PrincipalValue::String("xanadu".to_string()), - )], - ) - .await - .unwrap() - .unwrap_data(); - - assert_eq!(server.get_used_quota(tenant_id).await.unwrap(), 0); - assert_eq!( - server.get_used_quota(other_tenant_id).await.unwrap(), - TEST_MESSAGE.len() as i64 + EXTRA_BYTES - ); - - // Deleting tenants with data should fail - api.delete::<()>("/api/principal/xanadu") - .await - .unwrap() - .expect_error("Tenant has members"); - - // Delete user - api.delete::<()>("/api/principal/john.doe@foobar.org") - .await - .unwrap() - .unwrap_data(); - - // Quota usage for tenant should be updated - assert_eq!(server.get_used_quota(other_tenant_id).await.unwrap(), 0); - - // Delete tenant - api.delete::<()>("/api/principal/xanadu") - .await - .unwrap() - .unwrap_data(); - - // Delete tenant information - for query in [ - "/api/principal/no-mail-for-you@foobar.com", - "/api/principal/admin@foobar.org", - "/api/principal/foobar.org", - "/api/principal/foobar.com", - ] { - api.delete::<()>(query).await.unwrap().unwrap_data(); - } - - // Delete tenant - api.delete::<()>("/api/principal/foobar") - .await - .unwrap() - .unwrap_data(); - - server - .core - .storage - .config - .clear("report.domain") - .await - .unwrap(); - - params.assert_is_empty().await; -} - -const TENANT_QUOTA: u64 = TEST_MESSAGE.len() as u64; -const TEST_MESSAGE: &str = concat!( - "From: bill@foobar.org\r\n", - "To: jdoe@foobar.com\r\n", - "Subject: TPS Report\r\n", - "\r\n", - "I'm going to need those TPS reports ASAP. ", - "So, if you could do that, that'd be great." -); - -trait ValidatePrincipalList { - fn assert_exists<'x>( - self, - name: &str, - typ: Type, - items: impl IntoIterator, - ) -> Self; - fn assert_count(self, count: usize) -> Self; -} - -impl ValidatePrincipalList for List { - fn assert_exists<'x>( - self, - name: &str, - typ: Type, - items: impl IntoIterator, - ) -> Self { - for item in &self.items { - if item.name() == name { - item.validate(typ, items); - return self; - } - } - - panic!("Principal not found: {}", name); - } - - fn assert_count(self, count: usize) -> Self { - assert_eq!(self.items.len(), count, "Principal count failed validation"); - assert_eq!(self.total, count, "Principal total failed validation"); - self - } -} - -trait ValidatePrincipal { - fn validate<'x>( - &self, - typ: Type, - items: impl IntoIterator, - ); -} - -impl ValidatePrincipal for PrincipalSet { - fn validate<'x>( - &self, - typ: Type, - items: impl IntoIterator, - ) { - assert_eq!(self.typ(), typ, "Type failed validation"); - - for (field, values) in items { - match ( - self.get_str_array(field).filter(|v| !v.is_empty()), - (!values.is_empty()).then_some(values), - ) { - (Some(values), Some(expected)) => { - assert_eq!( - values.iter().map(|s| s.as_str()).collect::>(), - expected.iter().copied().collect::>(), - "Field {field:?} failed validation: {values:?} != {expected:?}" - ); - } - (None, None) => {} - (values, expected) => { - panic!("Field {field:?} failed validation: {values:?} != {expected:?}"); - } - } - } - } -} - -trait ValidatePermissions { - fn validate_permissions( - self, - expected_permissions: impl IntoIterator, - ) -> Self; - fn validate_tenant(self, tenant_id: u32, tenant_quota: u64) -> Self; -} - -impl ValidatePermissions for Arc { - fn validate_permissions( - self, - expected_permissions: impl IntoIterator, - ) -> Self { - let expected_permissions: AHashSet<_> = expected_permissions.into_iter().collect(); - - let permissions = self.permissions(); - for permission in &permissions { - assert!( - expected_permissions.contains(permission), - "Permission {:?} failed validation", - permission - ); - } - assert_eq!( - permissions.into_iter().collect::>(), - expected_permissions - ); - - for permission in Permission::all() { - if self.has_permission(permission) { - assert!( - expected_permissions.contains(&permission), - "Permission {:?} failed validation", - permission - ); - } - } - self - } - - fn validate_tenant(self, tenant_id: u32, tenant_quota: u64) -> Self { - assert_eq!( - self.tenant, - Some(TenantInfo { - id: tenant_id, - quota: tenant_quota - }) - ); - self - } -} diff --git a/tests/src/store/registry.rs b/tests/src/store/registry.rs index 23280671..1c025e4a 100644 --- a/tests/src/store/registry.rs +++ b/tests/src/store/registry.rs @@ -15,7 +15,7 @@ use registry::{ Account, Credential, CredentialPermissions, CredentialPermissionsList, CustomRoles, Domain, EmailAlias, EncryptionAtRest, EncryptionSettings, GroupAccount, MailingList, PasswordCredential, Permissions, PermissionsList, PublicKey, Roles, - SecondaryCredential, UserAccount, + SecondaryCredential, UserAccount, UserRoles, }, }, types::{ @@ -99,7 +99,7 @@ pub async fn test(test: &TestServer) { (StorageQuota::MaxDiskQuota, 1024u64), (StorageQuota::MaxApiKeys, 3u64), ]), - roles: Roles::Custom(CustomRoles { + roles: UserRoles::Custom(CustomRoles { role_ids: Map::new(vec![5000u64.into()]), }), }); diff --git a/tests/src/system/authorization.rs b/tests/src/system/authorization.rs new file mode 100644 index 00000000..20b4870b --- /dev/null +++ b/tests/src/system/authorization.rs @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::server::TestServer; + +pub async fn test(test: &mut TestServer) { + println!("Running authorization tests..."); + + /* + + pub async fn test(params: &JMAPTest) { + println!("Running permissions tests..."); + let server = params.server.clone(); + + // Disable spam filtering to avoid adding extra headers + let old_core = params.server.core.clone(); + let mut new_core = old_core.as_ref().clone(); + new_core.spam.enabled = false; + new_core.smtp.session.data.add_delivered_to = false; + params.server.inner.shared_core.store(Arc::new(new_core)); + + // Remove unlimited requests permission + for &account in params.accounts.keys() { + params + .server + .store() + .remove_permissions(account, [Permission::UnlimitedRequests]) + .await; + } + + // Prepare management API + let api = ManagementApi::new(8899, "admin", "secret"); + + // Create a user with the default 'user' role + let account_id = api + .post::( + "/api/principal", + &PrincipalSet::new(u32::MAX, Type::Individual) + .with_field(PrincipalField::Name, "role_player") + .with_field(PrincipalField::Roles, vec!["user".to_string()]) + .with_field( + PrincipalField::DisabledPermissions, + vec![Permission::Pop3Dele.name().to_string()], + ), + ) + .await + .unwrap() + .unwrap_data(); + let revision = server + .get_access_token(account_id) + .await + .unwrap() + .validate_permissions( + Permission::all().filter(|p| p.is_user_permission() && *p != Permission::Pop3Dele), + ) + .revision; + + // Create multiple roles + for (role, permissions, parent_role) in &[ + ( + "pop3_user", + vec![Permission::Pop3Authenticate, Permission::Pop3List], + vec![], + ), + ( + "imap_user", + vec![Permission::ImapAuthenticate, Permission::ImapList], + vec![], + ), + ( + "jmap_user", + vec![ + Permission::JmapEmailQuery, + Permission::AuthenticateOauth, + Permission::ManageEncryption, + ], + vec![], + ), + ( + "email_user", + vec![Permission::EmailSend, Permission::EmailReceive], + vec!["pop3_user", "imap_user", "jmap_user"], + ), + ] { + api.post::( + "/api/principal", + &PrincipalSet::new(u32::MAX, Type::Role) + .with_field(PrincipalField::Name, role.to_string()) + .with_field( + PrincipalField::EnabledPermissions, + permissions + .iter() + .map(|p| p.name().to_string()) + .collect::>(), + ) + .with_field( + PrincipalField::Roles, + parent_role + .iter() + .map(|r| r.to_string()) + .collect::>(), + ), + ) + .await + .unwrap() + .unwrap_data(); + } + + // Update email_user role + api.patch::<()>( + "/api/principal/email_user", + &vec![PrincipalUpdate::add_item( + PrincipalField::DisabledPermissions, + PrincipalValue::String(Permission::ManageEncryption.name().to_string()), + )], + ) + .await + .unwrap() + .unwrap_data(); + + // Update the user role to the nested 'email_user' role + api.patch::<()>( + "/api/principal/role_player", + &vec![PrincipalUpdate::set( + PrincipalField::Roles, + PrincipalValue::StringList(vec!["email_user".to_string()]), + )], + ) + .await + .unwrap() + .unwrap_data(); + assert_ne!( + server + .get_access_token(account_id) + .await + .unwrap() + .validate_permissions([ + Permission::EmailSend, + Permission::EmailReceive, + Permission::JmapEmailQuery, + Permission::AuthenticateOauth, + Permission::ImapAuthenticate, + Permission::ImapList, + Permission::Pop3Authenticate, + Permission::Pop3List, + ]) + .revision, + revision + ); + + // Query all principals + api.get::>("/api/principal") + .await + .unwrap() + .unwrap_data() + .assert_count(12) + .assert_exists( + "admin", + Type::Individual, + [ + (PrincipalField::Roles, &["admin"][..]), + (PrincipalField::Members, &[][..]), + (PrincipalField::EnabledPermissions, &[][..]), + (PrincipalField::DisabledPermissions, &[][..]), + ], + ) + .assert_exists( + "role_player", + Type::Individual, + [ + (PrincipalField::Roles, &["email_user"][..]), + (PrincipalField::Members, &[][..]), + (PrincipalField::EnabledPermissions, &[][..]), + ( + PrincipalField::DisabledPermissions, + &[Permission::Pop3Dele.name()][..], + ), + ], + ) + .assert_exists( + "email_user", + Type::Role, + [ + ( + PrincipalField::Roles, + &["pop3_user", "imap_user", "jmap_user"][..], + ), + (PrincipalField::Members, &["role_player"][..]), + ( + PrincipalField::EnabledPermissions, + &[ + Permission::EmailReceive.name(), + Permission::EmailSend.name(), + ][..], + ), + ( + PrincipalField::DisabledPermissions, + &[Permission::ManageEncryption.name()][..], + ), + ], + ) + .assert_exists( + "pop3_user", + Type::Role, + [ + (PrincipalField::Roles, &[][..]), + (PrincipalField::Members, &["email_user"][..]), + ( + PrincipalField::EnabledPermissions, + &[ + Permission::Pop3Authenticate.name(), + Permission::Pop3List.name(), + ][..], + ), + (PrincipalField::DisabledPermissions, &[][..]), + ], + ) + .assert_exists( + "imap_user", + Type::Role, + [ + (PrincipalField::Roles, &[][..]), + (PrincipalField::Members, &["email_user"][..]), + ( + PrincipalField::EnabledPermissions, + &[ + Permission::ImapAuthenticate.name(), + Permission::ImapList.name(), + ][..], + ), + (PrincipalField::DisabledPermissions, &[][..]), + ], + ) + .assert_exists( + "jmap_user", + Type::Role, + [ + (PrincipalField::Roles, &[][..]), + (PrincipalField::Members, &["email_user"][..]), + ( + PrincipalField::EnabledPermissions, + &[ + Permission::JmapEmailQuery.name(), + Permission::AuthenticateOauth.name(), + Permission::ManageEncryption.name(), + ][..], + ), + (PrincipalField::DisabledPermissions, &[][..]), + ], + ); + + // Verify permissions + server + .get_access_token(tenant_admin_id) + .await + .unwrap() + .validate_permissions(Permission::all().filter(|p| p.is_tenant_admin_permission())) + .validate_tenant(tenant_id, TENANT_QUOTA); + + // Prepare tenant admin API + let tenant_api = ManagementApi::new(8899, "admin@foobar.org", "mytenantpass"); + + // John should not be allowed to receive email + let (message_blob, _) = server + .put_temporary_blob(tenant_user_id, TEST_MESSAGE.as_bytes(), 60) + .await + .unwrap(); + assert_eq!( + server + .deliver_message(IngestMessage { + sender_address: "bill@foobar.org".to_string(), + sender_authenticated: true, + recipients: vec![IngestRecipient { + address: "john@foobar.org".to_string(), + is_spam: false + }], + message_blob: message_blob.clone(), + message_size: TEST_MESSAGE.len() as u64, + session_id: 0, + }) + .await + .status, + vec![LocalDeliveryStatus::PermanentFailure { + code: [5, 5, 0], + reason: "This account is not authorized to receive email.".into() + }] + ); + + // Remove the restriction + tenant_api + .patch::<()>( + "/api/principal/john.doe@foobar.org", + &vec![PrincipalUpdate::remove_item( + PrincipalField::Roles, + PrincipalValue::String("no-mail-for-you@foobar.com".to_string()), + )], + ) + .await + .unwrap() + .unwrap_data(); + server + .get_access_token(tenant_user_id) + .await + .unwrap() + .validate_permissions( + Permission::all().filter(|p| p.is_tenant_admin_permission() || p.is_user_permission()), + ); + } + + const TENANT_QUOTA: u64 = TEST_MESSAGE.len() as u64; + const TEST_MESSAGE: &str = concat!( + "From: bill@foobar.org\r\n", + "To: jdoe@foobar.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ); + + trait ValidatePrincipalList { + fn assert_exists<'x>( + self, + name: &str, + typ: Type, + items: impl IntoIterator, + ) -> Self; + fn assert_count(self, count: usize) -> Self; + } + + impl ValidatePrincipalList for List { + fn assert_exists<'x>( + self, + name: &str, + typ: Type, + items: impl IntoIterator, + ) -> Self { + for item in &self.items { + if item.name() == name { + item.validate(typ, items); + return self; + } + } + + panic!("Principal not found: {}", name); + } + + fn assert_count(self, count: usize) -> Self { + assert_eq!(self.items.len(), count, "Principal count failed validation"); + assert_eq!(self.total, count, "Principal total failed validation"); + self + } + } + + trait ValidatePrincipal { + fn validate<'x>( + &self, + typ: Type, + items: impl IntoIterator, + ); + } + + impl ValidatePrincipal for PrincipalSet { + fn validate<'x>( + &self, + typ: Type, + items: impl IntoIterator, + ) { + assert_eq!(self.typ(), typ, "Type failed validation"); + + for (field, values) in items { + match ( + self.get_str_array(field).filter(|v| !v.is_empty()), + (!values.is_empty()).then_some(values), + ) { + (Some(values), Some(expected)) => { + assert_eq!( + values.iter().map(|s| s.as_str()).collect::>(), + expected.iter().copied().collect::>(), + "Field {field:?} failed validation: {values:?} != {expected:?}" + ); + } + (None, None) => {} + (values, expected) => { + panic!("Field {field:?} failed validation: {values:?} != {expected:?}"); + } + } + } + } + } + + trait ValidatePermissions { + fn validate_permissions( + self, + expected_permissions: impl IntoIterator, + ) -> Self; + fn validate_tenant(self, tenant_id: u32, tenant_quota: u64) -> Self; + } + + impl ValidatePermissions for Arc { + fn validate_permissions( + self, + expected_permissions: impl IntoIterator, + ) -> Self { + let expected_permissions: AHashSet<_> = expected_permissions.into_iter().collect(); + + let permissions = self.permissions(); + for permission in &permissions { + assert!( + expected_permissions.contains(permission), + "Permission {:?} failed validation", + permission + ); + } + assert_eq!( + permissions.into_iter().collect::>(), + expected_permissions + ); + + for permission in Permission::all() { + if self.has_permission(permission) { + assert!( + expected_permissions.contains(&permission), + "Permission {:?} failed validation", + permission + ); + } + } + self + } + + fn validate_tenant(self, tenant_id: u32, tenant_quota: u64) -> Self { + assert_eq!( + self.tenant, + Some(TenantInfo { + id: tenant_id, + quota: tenant_quota + }) + ); + self + } + } + + + */ +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 71077fd8..ce2cbc37 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -5,8 +5,10 @@ */ pub mod authentication; +pub mod authorization; pub mod directory; pub mod oidc; +pub mod tenant; use crate::utils::server::TestServerBuilder; @@ -34,5 +36,6 @@ pub async fn system_tests() { //directory::test(&test).await; //authentication::test(&test).await; - oidc::test(&mut test).await; + //oidc::test(&mut test).await; + tenant::test(&mut test).await; } diff --git a/tests/src/system/tenant.rs b/tests/src/system/tenant.rs new file mode 100644 index 00000000..bb5bb46e --- /dev/null +++ b/tests/src/system/tenant.rs @@ -0,0 +1,678 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{jmap::JmapUtils, server::TestServer}; +use ahash::{AHashMap, AHashSet}; +use common::auth::BuildAccessToken; +use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use jmap_proto::error::set::SetErrorType; +use registry::{ + schema::{ + enums::{AccountType, Permission, TenantStorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + Account, Credential, Dkim1Signature, DkimPrivateKey, DkimSignature, DnsServer, + DnsServerCloudflare, Domain, GroupAccount, MailingList, OAuthClient, + PasswordCredential, Permissions, PermissionsList, Role, SecretKey, SecretKeyValue, + SecretTextValue, Tenant, UserAccount, UserRoles, + }, + }, + types::{EnumImpl, ObjectImpl, list::List, map::Map}, +}; +use serde_json::json; +use types::id::Id; +use utils::map::vec_map::VecMap; + +pub async fn test(test: &mut TestServer) { + println!("Running multi-tenancy tests..."); + let account = test.account("admin@example.org"); + + // Create tenants + let mut tenant_x_ids = AHashMap::new(); + let mut tenant_y_ids = AHashMap::new(); + for (tenant_ids, name) in [(&mut tenant_x_ids, "x"), (&mut tenant_y_ids, "y")] { + let tenant_id = account + .registry_create_object(Tenant { + name: format!("Tenant {}", name), + quotas: VecMap::from_iter([ + (TenantStorageQuota::MaxAccounts, 2), + (TenantStorageQuota::MaxGroups, 1), + (TenantStorageQuota::MaxDomains, 1), + (TenantStorageQuota::MaxMailingLists, 1), + (TenantStorageQuota::MaxRoles, 1), + (TenantStorageQuota::MaxOauthClients, 1), + (TenantStorageQuota::MaxDkimKeys, 1), + (TenantStorageQuota::MaxDnsServers, 1), + (TenantStorageQuota::MaxDiskQuota, TENANT_QUOTA), + ]), + ..Default::default() + }) + .await; + + let domain_id = account + .registry_create_object(Domain { + name: format!("tenant{name}.org"), + member_tenant_id: tenant_id.into(), + ..Default::default() + }) + .await; + + let tenant_admin_id = account + .registry_create_object(Account::User(UserAccount { + name: "admin".to_string(), + domain_id, + member_tenant_id: tenant_id.into(), + roles: UserRoles::TenantAdmin, + description: format!("Tenant {name} Admin").into(), + credentials: List::from_iter([Credential::Password(PasswordCredential { + secret: format!("tenant {name} secret"), + ..Default::default() + })]), + ..Default::default() + })) + .await; + + tenant_ids.insert(ObjectType::Tenant, tenant_id); + tenant_ids.insert(ObjectType::Domain, domain_id); + tenant_ids.insert(ObjectType::TaskManager, tenant_admin_id); + } + + // Tenants should not be allowed to create new tenants or modify their own tenant records + let admin_x = crate::utils::account::Account::new( + "admin@tenantx.org", + "tenant x secret", + &[], + tenant_x_ids[&ObjectType::TaskManager], + ) + .await; + let admin_y = crate::utils::account::Account::new( + "admin@tenanty.org", + "tenant y secret", + &[], + tenant_y_ids[&ObjectType::TaskManager], + ) + .await; + assert_eq!( + admin_x + .registry_create([Tenant { + name: "Tenant Z".to_string(), + ..Default::default() + }]) + .await + .method_response() + .text_field("type"), + "forbidden" + ); + assert_eq!( + admin_x + .registry_update( + ObjectType::Tenant, + [( + tenant_x_ids[&ObjectType::Tenant], + json!({ + "name": "New Tenant X Name" + }), + )], + ) + .await + .method_response() + .text_field("type"), + "forbidden" + ); + + // Tenants should not be able to create accounts without a domain + admin_x + .registry_create_object_expect_err(Account::User(UserAccount { + name: "user".to_string(), + ..Default::default() + })) + .await + .assert_type(SetErrorType::ValidationFailed); + + // Test wrong tenant assignment, invalid domain, exceeding tenant quotas, and successful creation for each object type + let mut tenant_ids = [tenant_x_ids, tenant_y_ids]; + let mut tests_run = AHashSet::new(); + for (admin, tenant_id_pos, other_tenant_id_pos) in [(&admin_x, 0, 1), (&admin_y, 1, 0)] { + for result in [ + ExpectedResult::WrongTenant, + ExpectedResult::InvalidDomain, + ExpectedResult::Success, + ExpectedResult::QuotaExceeded, + ] { + if result != ExpectedResult::Success && tenant_id_pos == 1 { + // Skip testing wrong tenant and invalid domain for tenant y, as they would be the same as tenant x + continue; + } + + tests_run.insert(result); + + let member_tenant_id = if result == ExpectedResult::WrongTenant { + Some(tenant_ids[other_tenant_id_pos][&ObjectType::Tenant]) + } else { + None + }; + let domain_id = if result == ExpectedResult::InvalidDomain { + tenant_ids[other_tenant_id_pos][&ObjectType::Domain] + } else { + tenant_ids[tenant_id_pos][&ObjectType::Domain] + }; + + if matches!( + result, + ExpectedResult::WrongTenant | ExpectedResult::QuotaExceeded + ) { + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::Domain, + Domain { + name: format!("tenant{tenant_id_pos}.org"), + member_tenant_id, + ..Default::default() + }, + ) + .await; + } + + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::Account, + Account::User(UserAccount { + name: "user".to_string(), + domain_id, + member_tenant_id, + ..Default::default() + }), + ) + .await; + + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::AccountSettings, + Account::Group(GroupAccount { + name: "group".to_string(), + domain_id, + member_tenant_id, + ..Default::default() + }), + ) + .await; + + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::MailingList, + MailingList { + name: "list".to_string(), + domain_id, + member_tenant_id, + ..Default::default() + }, + ) + .await; + + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::DkimSignature, + DkimSignature::Dkim1Ed25519Sha256(Dkim1Signature { + domain_id, + member_tenant_id, + selector: "ed-key".to_string(), + private_key: DkimPrivateKey::Value(SecretTextValue { + secret: DKIM_KEY.to_string(), + }), + ..Default::default() + }), + ) + .await; + + if result != ExpectedResult::InvalidDomain { + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::Role, + Role { + description: "role".to_string(), + member_tenant_id, + ..Default::default() + }, + ) + .await; + + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::OAuthClient, + OAuthClient { + client_id: format!("oauth-{tenant_id_pos}"), + member_tenant_id, + ..Default::default() + }, + ) + .await; + + result + .assert( + admin, + &mut tenant_ids[tenant_id_pos], + ObjectType::DnsServer, + DnsServer::Cloudflare(DnsServerCloudflare { + member_tenant_id, + secret: SecretKey::Value(SecretKeyValue { + secret: "abc".to_string(), + }), + ..Default::default() + }), + ) + .await; + } + } + } + assert_eq!(tests_run.len(), 4, "Not all expected results were tested"); + let tenant_x_ids = &tenant_ids[0]; + let tenant_y_ids = &tenant_ids[1]; + + // Assigning permissions not assigned to the tenant should be removed + let user_x_id = tenant_x_ids[&ObjectType::Account]; + let user_x_at = test + .server + .access_token(user_x_id.document_id()) + .await + .unwrap() + .build(); + assert!(!user_x_at.has_permission(Permission::FetchAnyBlob)); + assert!(!user_x_at.has_permission(Permission::Impersonate)); + admin_x + .registry_update_object( + ObjectType::Account, + user_x_id, + json!({ + Property::Permissions: Permissions::Merge( + PermissionsList { disabled_permissions: Map::default(), + enabled_permissions: Map::new(vec![Permission::FetchAnyBlob, Permission::Impersonate]) + }) + }), + ) + .await; + let user_x_at = test + .server + .access_token(user_x_id.document_id()) + .await + .unwrap() + .build(); + assert!(user_x_at.has_permission(Permission::FetchAnyBlob)); + assert!(!user_x_at.has_permission(Permission::Impersonate)); + + // Tenants should only see their own objects + for object_type in [ + ObjectType::Domain, + ObjectType::Account, + ObjectType::AccountSettings, + ObjectType::MailingList, + ObjectType::Role, + ObjectType::OAuthClient, + ObjectType::DnsServer, + ObjectType::DkimSignature, + ] { + let expected_id = tenant_y_ids[&object_type]; + match object_type { + ObjectType::Account => { + let expected = vec![tenant_y_ids[&ObjectType::TaskManager], expected_id]; + assert_eq!( + admin_y + .registry_query( + ObjectType::Account, + [(Property::Type, AccountType::User.as_str())], + Vec::<&str>::new() + ) + .await, + expected + ); + assert_eq!( + admin_y + .registry_get_many(ObjectType::Account, expected.iter()) + .await + .list() + .len(), + expected.len() + ); + } + ObjectType::AccountSettings => { + assert_eq!( + admin_y + .registry_query( + ObjectType::Account, + [(Property::Type, AccountType::Group.as_str())], + Vec::<&str>::new() + ) + .await, + vec![expected_id] + ); + + // Fetch a single id and make sure member tenant id is not included in the response + let account = admin_y.registry_get::(expected_id).await; + assert_eq!(account.into_group().unwrap().member_tenant_id, None); + + // Fetch all account types + assert_eq!( + admin_y + .registry_get_many(ObjectType::Account, Vec::<&str>::new()) + .await + .list() + .len(), + 3 + ); + } + _ => { + assert_eq!( + admin_y + .registry_query(object_type, Vec::<(&str, &str)>::new(), Vec::<&str>::new()) + .await, + vec![expected_id] + ); + assert_eq!( + admin_y + .registry_get_many(object_type, vec![expected_id]) + .await + .list() + .len(), + 1 + ); + assert_eq!( + admin_y + .registry_get_many(object_type, Vec::<&str>::new()) + .await + .list() + .len(), + 1 + ); + } + } + } + + // Tenants should not see, modify or destroy objects from other tenants, even if they have the id + for object_type in [ + ObjectType::Domain, + ObjectType::Account, + ObjectType::AccountSettings, + ObjectType::MailingList, + ObjectType::Role, + ObjectType::OAuthClient, + ObjectType::DnsServer, + ObjectType::DkimSignature, + ] { + let foreign_id = tenant_x_ids[&object_type]; + match object_type { + ObjectType::Account => { + assert_eq!( + admin_y + .registry_get_many( + ObjectType::Account, + vec![tenant_x_ids[&ObjectType::TaskManager], foreign_id] + ) + .await + .list() + .len(), + 0 + ); + + admin_y + .registry_update_object_expect_err( + ObjectType::Account, + foreign_id, + json!({ + "name": "hacked" + }), + ) + .await + .assert_type(SetErrorType::NotFound); + + admin_y + .registry_destroy_object_expect_err(ObjectType::Account, foreign_id) + .await + .assert_type(SetErrorType::NotFound); + } + ObjectType::AccountSettings => { + assert_eq!( + admin_y + .registry_get_many(ObjectType::Account, vec![foreign_id]) + .await + .list() + .len(), + 0 + ); + + admin_y + .registry_update_object_expect_err( + ObjectType::Account, + foreign_id, + json!({ + "name": "hacked" + }), + ) + .await + .assert_type(SetErrorType::NotFound); + + admin_y + .registry_destroy_object_expect_err(ObjectType::Account, foreign_id) + .await + .assert_type(SetErrorType::NotFound); + } + _ => { + assert_eq!( + admin_y + .registry_get_many(object_type, vec![foreign_id]) + .await + .list() + .len(), + 0 + ); + + admin_y + .registry_update_object_expect_err( + object_type, + foreign_id, + json!({ + "name": "hacked" + }), + ) + .await + .assert_type(SetErrorType::NotFound); + + admin_y + .registry_destroy_object_expect_err(object_type, foreign_id) + .await + .assert_type(SetErrorType::NotFound); + } + } + } + + // Test tenant quotas + let (message_blob, _) = test + .server + .put_temporary_blob(user_x_id.document_id(), TEST_MESSAGE.as_bytes(), 60) + .await + .unwrap(); + assert_eq!( + test.server + .deliver_message(IngestMessage { + sender_address: "bill@foobar.org".to_string(), + sender_authenticated: true, + recipients: vec![IngestRecipient { + address: "user@tenantx.org".to_string(), + is_spam: false + }], + message_blob: message_blob.clone(), + message_size: TEST_MESSAGE.len() as u64, + session_id: 0, + }) + .await + .status, + vec![LocalDeliveryStatus::Success] + ); + + // Quota for the tenant and user should be updated + const EXTRA_BYTES: i64 = 51; // Storage overhead + assert_eq!( + test.server + .get_used_quota_account(user_x_id.document_id()) + .await + .unwrap(), + TEST_MESSAGE.len() as i64 + EXTRA_BYTES + ); + assert_eq!( + test.server + .get_used_quota_tenant(tenant_x_ids[&ObjectType::Tenant].document_id()) + .await + .unwrap(), + TEST_MESSAGE.len() as i64 + EXTRA_BYTES + ); + + // Next delivery should fail due to tenant quota + assert_eq!( + test.server + .deliver_message(IngestMessage { + sender_address: "bill@foobar.org".to_string(), + sender_authenticated: true, + recipients: vec![IngestRecipient { + address: "user@tenantx.org".to_string(), + is_spam: false + }], + message_blob, + message_size: TEST_MESSAGE.len() as u64, + session_id: 0, + }) + .await + .status, + vec![LocalDeliveryStatus::TemporaryFailure { + reason: "Organization over quota.".into() + }] + ); + + // Delete everything created during the test + for (admin, tenant_id_pos) in [(&admin_x, 0), (&admin_y, 1)] { + for object_type in [ + ObjectType::Account, + ObjectType::AccountSettings, + ObjectType::MailingList, + ObjectType::Role, + ObjectType::OAuthClient, + ObjectType::DnsServer, + ObjectType::DkimSignature, + ] { + let id = tenant_ids[tenant_id_pos][&object_type]; + let object_type = if object_type == ObjectType::AccountSettings { + ObjectType::Account + } else { + object_type + }; + assert_eq!( + admin + .registry_destroy(object_type, [id]) + .await + .destroyed_ids() + .count(), + 1 + ); + } + } + + for tenant_id_pos in [0, 1] { + for object_type in [ + ObjectType::TaskManager, + ObjectType::Domain, + ObjectType::Tenant, + ] { + let id = tenant_ids[tenant_id_pos][&object_type]; + let object_type = if object_type == ObjectType::TaskManager { + ObjectType::Account + } else { + object_type + }; + assert_eq!( + account + .registry_destroy(object_type, [id]) + .await + .destroyed_ids() + .count(), + 1 + ); + } + } + test.assert_is_empty().await; +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] +enum ExpectedResult { + Success, + WrongTenant, + QuotaExceeded, + InvalidDomain, +} + +impl ExpectedResult { + async fn assert( + &self, + account: &crate::utils::account::Account, + ids: &mut AHashMap, + item_type: ObjectType, + item: T, + ) { + match self { + ExpectedResult::Success => { + assert!( + ids.insert(item_type, account.registry_create_object(item).await) + .is_none(), + "Expected to create object successfully, but it already exists" + ) + } + ExpectedResult::WrongTenant => { + account + .registry_create_object_expect_err(item) + .await + .assert_type(SetErrorType::InvalidPatch) + .assert_description_contains("Cannot modify memberTenantId property"); + } + ExpectedResult::QuotaExceeded => { + account + .registry_create_object_expect_err(item) + .await + .assert_type(SetErrorType::OverQuota); + } + ExpectedResult::InvalidDomain => { + account + .registry_create_object_expect_err(item) + .await + .assert_type(SetErrorType::InvalidForeignKey); + } + } + } +} + +const TENANT_QUOTA: u64 = TEST_MESSAGE.len() as u64; +const TEST_MESSAGE: &str = concat!( + "From: bill@foobar.org\r\n", + "To: jdoe@foobar.com\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." +); + +const DKIM_KEY: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIAO3hAf144lTAVjTkht3ZwBTK0CMCCd1bI0alggneN3B +-----END PRIVATE KEY----- +"; diff --git a/tests/src/utils/registry.rs b/tests/src/utils/registry.rs index a8fc3697..03d764a8 100644 --- a/tests/src/utils/registry.rs +++ b/tests/src/utils/registry.rs @@ -41,6 +41,31 @@ impl Account { .await } + pub async fn registry_get(&self, id: Id) -> T { + let name = T::OBJECT.as_str(); + + let value = self + .jmap_get_account(self, format!("x:{name}"), Vec::<&str>::new(), vec![id]) + .await + .list()[0] + .to_string(); + serde_json::from_str(&value).expect("Failed to deserialize item") + } + + pub async fn registry_get_many( + &self, + object_type: ObjectType, + ids: impl IntoIterator, + ) -> JmapResponse { + self.jmap_get_account( + self, + format!("x:{}", object_type.as_str()), + Vec::<&str>::new(), + ids, + ) + .await + } + pub async fn registry_update( &self, object: ObjectType, @@ -206,8 +231,10 @@ fn remove_server_set_props(value: &mut serde_json::Value) { .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")) + obj.retain(|k, v| { + !(["createdAt", "credentialId", "retireAt"].contains(&k.as_str()) + || (is_app_pass && k == "secret") + || (k == "memberTenantId" && v.is_null())) }); for v in obj.values_mut() { remove_server_set_props(v);