diff options
author | mdecimus <mauro@stalw.art> | 2024-09-18 18:08:57 +0200 |
---|---|---|
committer | mdecimus <mauro@stalw.art> | 2024-09-18 18:08:57 +0200 |
commit | e9d12aea44930d5136a67fc9536d55d31d9a1ddc (patch) | |
tree | 7f6cb7fdee263ea3d2fdb0ec7eae321cc3de12b0 | |
parent | d0303aefa8f5c0e8700326f979af17669cb8d325 (diff) |
Permissions & multi-tenancy test suite
-rw-r--r-- | crates/common/src/auth/mod.rs | 2 | ||||
-rw-r--r-- | crates/common/src/lib.rs | 2 | ||||
-rw-r--r-- | crates/directory/src/backend/internal/manage.rs | 145 | ||||
-rw-r--r-- | crates/directory/src/core/principal.rs | 87 | ||||
-rw-r--r-- | crates/jmap/src/api/http.rs | 16 | ||||
-rw-r--r-- | crates/jmap/src/api/management/mod.rs | 22 | ||||
-rw-r--r-- | crates/jmap/src/api/management/principal.rs | 32 | ||||
-rw-r--r-- | crates/trc/src/event/level.rs | 2 | ||||
-rw-r--r-- | tests/src/jmap/auth_oauth.rs | 2 | ||||
-rw-r--r-- | tests/src/jmap/mod.rs | 111 | ||||
-rw-r--r-- | tests/src/jmap/permissions.rs | 862 | ||||
-rw-r--r-- | tests/src/smtp/management/queue.rs | 2 |
12 files changed, 1186 insertions, 99 deletions
diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index aebb46e7..25ec299b 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -23,7 +23,7 @@ pub struct AccessToken { pub tenant: Option<TenantInfo>, } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct TenantInfo { pub id: u32, pub quota: u64, diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ffb6f6b2..a5c6df41 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -119,7 +119,7 @@ pub struct IngestMessage { pub session_id: u64, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum DeliveryResult { Success, TemporaryFailure { diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index 6b92cc30..89d4c0f8 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -197,26 +197,6 @@ impl ManageDirectory for Store { .ctx(trc::Key::Total, total)); } } - - // Tenants must provide principal names including a valid domain - if let Some(domain) = name.split('@').nth(1) { - if self - .get_principal_info(domain) - .await - .caused_by(trc::location!())? - .filter(|v| v.typ == Type::Domain && v.has_tenant_access(tenant_id.into())) - .is_some() - { - valid_domains.insert(domain.to_string()); - } - } - - if valid_domains.is_empty() { - return Err(error( - "Invalid principal name", - "Principal name must include a valid domain".into(), - )); - } } // Make sure new name is not taken @@ -228,6 +208,60 @@ impl ManageDirectory for Store { { return Err(err_exists(PrincipalField::Name, name)); } + + // Obtain tenant id, only if no default tenant is provided + if let (Some(tenant_name), None) = (principal.take_str(PrincipalField::Tenant), tenant_id) { + tenant_id = self + .get_principal_info(&tenant_name) + .await + .caused_by(trc::location!())? + .filter(|v| v.typ == Type::Tenant) + .ok_or_else(|| not_found(tenant_name.clone()))? + .id + .into(); + } + + // Tenants must provide principal names including a valid domain + if let Some(tenant_id) = tenant_id { + if matches!(principal.typ, Type::Tenant) { + return Err(error( + "Invalid field", + "Tenants cannot contain a tenant field".into(), + )); + } + + principal.set(PrincipalField::Tenant, tenant_id); + + if matches!( + principal.typ, + Type::Individual + | Type::Group + | Type::List + | Type::Role + | Type::Location + | Type::Resource + | Type::Other + ) { + if let Some(domain) = name.split('@').nth(1) { + if self + .get_principal_info(domain) + .await + .caused_by(trc::location!())? + .filter(|v| v.typ == Type::Domain && v.has_tenant_access(tenant_id.into())) + .is_some() + { + valid_domains.insert(domain.to_string()); + } + } + + if valid_domains.is_empty() { + return Err(error( + "Invalid principal name", + "Principal name must include a valid domain assigned to the tenant".into(), + )); + } + } + } principal.set(PrincipalField::Name, name); // Map member names @@ -307,20 +341,6 @@ impl ManageDirectory for Store { } } - // Obtain tenant id - if let Some(tenant_id) = tenant_id { - principal.set(PrincipalField::Tenant, tenant_id); - } else if let Some(tenant_name) = principal.take_str(PrincipalField::Tenant) { - tenant_id = self - .get_principal_info(&tenant_name) - .await - .caused_by(trc::location!())? - .filter(|v| v.typ == Type::Tenant) - .ok_or_else(|| not_found(tenant_name.clone()))? - .id - .into(); - } - // Write principal let mut batch = BatchBuilder::new(); let pinfo_name = DynamicPrincipalInfo::new(principal.typ, tenant_id); @@ -648,7 +668,18 @@ impl ManageDirectory for Store { // Make sure new name is not taken let new_name = new_name.to_lowercase(); if principal.inner.name() != new_name { - if tenant_id.is_some() { + if tenant_id.is_some() + && matches!( + principal.inner.typ, + Type::Individual + | Type::Group + | Type::List + | Type::Role + | Type::Location + | Type::Resource + | Type::Other + ) + { if let Some(domain) = new_name.split('@').nth(1) { if self .get_principal_info(domain) @@ -666,7 +697,7 @@ impl ManageDirectory for Store { if valid_domains.is_empty() { return Err(error( "Invalid principal name", - "Principal name must include a valid domain".into(), + "Principal name must include a valid domain assigned to the tenant".into(), )); } } @@ -1340,7 +1371,8 @@ impl ManageDirectory for Store { || fields.iter().any(|f| { matches!( f, - PrincipalField::MemberOf + PrincipalField::Tenant + | PrincipalField::MemberOf | PrincipalField::Lists | PrincipalField::Roles | PrincipalField::EnabledPermissions @@ -1353,9 +1385,7 @@ impl ManageDirectory for Store { for mut principal in results { if !is_done || filters.is_some() { principal = self - .get_value::<Principal>(ValueKey::from(ValueClass::Directory( - DirectoryClass::Principal(principal.id), - ))) + .query(QueryBy::Id(principal.id), map_principals) .await .caused_by(trc::location!())? .ok_or_else(|| not_found(principal.name().to_string()))?; @@ -1581,6 +1611,19 @@ impl ManageDirectory for Store { } } + // Map tenant name + if let Some(tenant_id) = principal.take_int(PrincipalField::Tenant) { + if fields.is_empty() || fields.contains(&PrincipalField::Tenant) { + if let Some(name) = self + .get_principal_name(tenant_id as u32) + .await + .caused_by(trc::location!())? + { + principal.set(PrincipalField::Tenant, name); + } + } + } + // Obtain used quota if matches!(principal.typ, Type::Individual | Type::Group | Type::Tenant) && (fields.is_empty() || fields.contains(&PrincipalField::UsedQuota)) @@ -1659,15 +1702,19 @@ fn validate_member_of( if expected_types.is_empty() || !expected_types.contains(&member_type) { Err(error( format!("Invalid {} value", field.as_str()), - format!( - "Principal {member_name:?} is not a {}.", - expected_types - .iter() - .map(|t| t.as_str().to_string()) - .collect::<Vec<_>>() - .join(", ") - ) - .into(), + if !expected_types.is_empty() { + format!( + "Principal {member_name:?} is not a {}.", + expected_types + .iter() + .map(|t| t.as_str().to_string()) + .collect::<Vec<_>>() + .join(", ") + ) + .into() + } else { + format!("Principal {member_name:?} cannot be added as a member.").into() + }, )) } else { Ok(()) diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index f17ba207..5d134e73 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -92,6 +92,15 @@ impl Principal { }) } + pub fn take_int(&mut self, key: PrincipalField) -> Option<u64> { + self.take(key).and_then(|v| match v { + PrincipalValue::Integer(i) => Some(i), + PrincipalValue::IntegerList(l) => l.into_iter().next(), + PrincipalValue::String(s) => s.parse().ok(), + PrincipalValue::StringList(l) => l.into_iter().next().and_then(|s| s.parse().ok()), + }) + } + pub fn take_str_array(&mut self, key: PrincipalField) -> Option<Vec<String>> { self.take(key).map(|v| v.into_str_array()) } @@ -697,9 +706,19 @@ impl<'de> serde::Deserialize<'de> for Principal { let mut principal = Principal::default(); while let Some(key) = map.next_key::<&str>()? { - let key = PrincipalField::try_parse(key).ok_or_else(|| { - serde::de::Error::custom(format!("invalid principal field: {}", key)) - })?; + let key = PrincipalField::try_parse(key) + .or_else(|| { + if key == "id" { + // Ignored + Some(PrincipalField::UsedQuota) + } else { + None + } + }) + .ok_or_else(|| { + serde::de::Error::custom(format!("invalid principal field: {}", key)) + })?; + let value = match key { PrincipalField::Name => PrincipalValue::String(map.next_value()?), PrincipalField::Description @@ -711,7 +730,6 @@ impl<'de> serde::Deserialize<'de> for Principal { continue; } } - PrincipalField::Type => { principal.typ = Type::parse(map.next_value()?).ok_or_else(|| { serde::de::Error::custom("invalid principal type") @@ -719,7 +737,6 @@ impl<'de> serde::Deserialize<'de> for Principal { continue; } PrincipalField::Quota => map.next_value::<PrincipalValue>()?, - PrincipalField::Secrets | PrincipalField::Emails | PrincipalField::MemberOf @@ -728,7 +745,16 @@ impl<'de> serde::Deserialize<'de> for Principal { | PrincipalField::Lists | PrincipalField::EnabledPermissions | PrincipalField::DisabledPermissions => { - PrincipalValue::StringList(map.next_value()?) + match map.next_value::<StringOrMany>()? { + StringOrMany::One(v) => PrincipalValue::StringList(vec![v]), + StringOrMany::Many(v) => { + if !v.is_empty() { + PrincipalValue::StringList(v) + } else { + continue; + } + } + } } PrincipalField::UsedQuota => { // consume and ignore @@ -787,7 +813,56 @@ impl<'de> serde::Deserialize<'de> for StringOrU64 { } } +#[derive(Debug)] +enum StringOrMany { + One(String), + Many(Vec<String>), +} + +impl<'de> serde::Deserialize<'de> for StringOrMany { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + struct StringOrManyVisitor; + + impl<'de> Visitor<'de> for StringOrManyVisitor { + type Value = StringOrMany; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string or a sequence of strings") + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: de::Error, + { + Ok(StringOrMany::One(value.to_string())) + } + + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: de::SeqAccess<'de>, + { + let mut vec = Vec::new(); + + while let Some(value) = seq.next_element::<String>()? { + vec.push(value); + } + + Ok(StringOrMany::Many(vec)) + } + } + + deserializer.deserialize_any(StringOrManyVisitor) + } +} + impl Permission { + pub fn all() -> impl Iterator<Item = Permission> { + (0..Permission::COUNT).filter_map(Permission::from_id) + } + pub const fn is_user_permission(&self) -> bool { matches!( self, diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 8988bbd5..16ad3c39 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -818,11 +818,6 @@ impl ToHttpResponse for &trc::Error { fn into_http_response(self) -> HttpResponse { match self.as_ref() { trc::EventType::Manage(cause) => { - let details_or_reason = self - .value(trc::Key::Details) - .or_else(|| self.value(trc::Key::Reason)) - .and_then(|v| v.as_str()); - match cause { trc::ManageEvent::MissingParameter => ManagementApiError::FieldMissing { field: self.value_as_str(trc::Key::Key).unwrap_or_default(), @@ -835,11 +830,18 @@ impl ToHttpResponse for &trc::Error { item: self.value_as_str(trc::Key::Key).unwrap_or_default(), }, trc::ManageEvent::NotSupported => ManagementApiError::Unsupported { - details: details_or_reason.unwrap_or("Requested action is unsupported"), + details: self + .value(trc::Key::Details) + .or_else(|| self.value(trc::Key::Reason)) + .and_then(|v| v.as_str()) + .unwrap_or("Requested action is unsupported"), }, trc::ManageEvent::AssertFailed => ManagementApiError::AssertFailed, trc::ManageEvent::Error => ManagementApiError::Other { - details: details_or_reason.unwrap_or("An error occurred."), + reason: self.value_as_str(trc::Key::Reason), + details: self + .value_as_str(trc::Key::Details) + .unwrap_or("Unknown error"), }, } } diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index b88f78fa..0a3dc0c2 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -33,12 +33,24 @@ use crate::JMAP; #[serde(tag = "error")] #[serde(rename_all = "camelCase")] pub enum ManagementApiError<'x> { - FieldAlreadyExists { field: &'x str, value: &'x str }, - FieldMissing { field: &'x str }, - NotFound { item: &'x str }, - Unsupported { details: &'x str }, + FieldAlreadyExists { + field: &'x str, + value: &'x str, + }, + FieldMissing { + field: &'x str, + }, + NotFound { + item: &'x str, + }, + Unsupported { + details: &'x str, + }, AssertFailed, - Other { details: &'x str }, + Other { + details: &'x str, + reason: Option<&'x str>, + }, } impl JMAP { diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index 9302ccfe..f3944a81 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -85,7 +85,7 @@ impl JMAP { } // Make sure the current directory supports updates - if matches!(principal.typ(), Type::Individual | Type::Group | Type::List) { + if matches!(principal.typ(), Type::Individual) { self.assert_supported_directory()?; } @@ -315,27 +315,27 @@ impl JMAP { // Validate changes let mut needs_assert = false; - let mut is_password_change = false; + let mut expire_session = false; + let mut expire_token = false; let mut is_role_change = false; for change in &changes { match change.field { - PrincipalField::Name - | PrincipalField::Emails - | PrincipalField::MemberOf - | PrincipalField::Members - | PrincipalField::Lists => { + PrincipalField::Name | PrincipalField::Emails => { + needs_assert = true; + } + PrincipalField::Secrets => { + expire_session = true; needs_assert = true; } PrincipalField::Quota | PrincipalField::UsedQuota | PrincipalField::Description | PrincipalField::Type - | PrincipalField::Picture => (), - PrincipalField::Secrets => { - is_password_change = true; - needs_assert = true; - } + | PrincipalField::Picture + | PrincipalField::MemberOf + | PrincipalField::Members + | PrincipalField::Lists => (), PrincipalField::Tenant => { // Tenants are not allowed to change their tenantId if access_token.tenant.is_some() { @@ -353,6 +353,8 @@ impl JMAP { | PrincipalField::DisabledPermissions => { if matches!(typ, Type::Role | Type::Tenant) { is_role_change = true; + } else { + expire_token = true; } if change.field == PrincipalField::Roles { needs_assert = true; @@ -376,7 +378,7 @@ impl JMAP { ) .await?; - if is_password_change { + if expire_session { // Remove entries from cache self.inner.sessions.retain(|_, id| id.item != account_id); } @@ -390,6 +392,10 @@ impl JMAP { .fetch_add(1, Ordering::Relaxed); } + if expire_token { + self.core.security.access_tokens.remove(&account_id); + } + Ok(JsonResponse::new(json!({ "data": (), })) diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index 8b7a06ed..514c7eee 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -345,7 +345,7 @@ impl EventType { SpamEvent::ListUpdated => Level::Info, }, EventType::Http(event) => match event { - HttpEvent::ConnectionStart | HttpEvent::ConnectionEnd => Level::Info, + HttpEvent::ConnectionStart | HttpEvent::ConnectionEnd => Level::Debug, HttpEvent::XForwardedMissing => Level::Warn, HttpEvent::Error | HttpEvent::RequestUrl => Level::Debug, HttpEvent::RequestBody | HttpEvent::ResponseBody => Level::Trace, diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs index 4977c01e..fa656fcc 100644 --- a/tests/src/jmap/auth_oauth.rs +++ b/tests/src/jmap/auth_oauth.rs @@ -25,7 +25,7 @@ use crate::{ use super::JMAPTest; -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] #[allow(dead_code)] struct OAuthCodeResponse { pub code: String, diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 1978cec5..76bd961d 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -5,6 +5,7 @@ */ use std::{ + fmt::Debug, path::PathBuf, sync::Arc, time::{Duration, Instant}, @@ -66,6 +67,7 @@ pub mod email_submission; pub mod enterprise; pub mod event_source; pub mod mailbox; +pub mod permissions; pub mod purge; pub mod push_subscription; pub mod quota; @@ -310,7 +312,7 @@ pub async fn jmap_tests() { .await; webhooks::test(&mut params).await; - /* //email_query::test(&mut params, delete).await; + email_query::test(&mut params, delete).await; email_get::test(&mut params).await; email_set::test(&mut params).await; email_parse::test(&mut params).await; @@ -318,8 +320,8 @@ pub async fn jmap_tests() { email_changes::test(&mut params).await; email_query_changes::test(&mut params).await; email_copy::test(&mut params).await; - //thread_get::test(&mut params).await; - //thread_merge::test(&mut params).await; + thread_get::test(&mut params).await; + thread_merge::test(&mut params).await; mailbox::test(&mut params).await; delivery::test(&mut params).await; auth_acl::test(&mut params).await; @@ -333,7 +335,8 @@ pub async fn jmap_tests() { websocket::test(&mut params).await; quota::test(&mut params).await; crypto::test(&mut params).await; - blob::test(&mut params).await;*/ + blob::test(&mut params).await; + permissions::test(¶ms).await; purge::test(&mut params).await; enterprise::test(&mut params).await; @@ -740,8 +743,15 @@ pub async fn test_account_login(login: &str, secret: &str) -> Client { #[serde(untagged)] pub enum Response<T> { RequestError(RequestError<'static>), - Error { error: String, details: String }, - Data { data: T }, + Error { + error: String, + details: Option<String>, + item: Option<String>, + reason: Option<String>, + }, + Data { + data: T, + }, } pub struct ManagementApi { @@ -786,6 +796,32 @@ impl ManagementApi { }) } + pub async fn patch<T: DeserializeOwned>( + &self, + query: &str, + body: &impl Serialize, + ) -> Result<Response<T>, String> { + self.request_raw( + Method::PATCH, + query, + Some(serde_json::to_string(body).unwrap()), + ) + .await + .map(|result| { + serde_json::from_str::<Response<T>>(&result) + .unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn delete<T: DeserializeOwned>(&self, query: &str) -> Result<Response<T>, String> { + self.request_raw(Method::DELETE, query, None) + .await + .map(|result| { + serde_json::from_str::<Response<T>>(&result) + .unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + pub async fn get<T: DeserializeOwned>(&self, query: &str) -> Result<Response<T>, String> { self.request_raw(Method::GET, query, None) .await @@ -840,12 +876,17 @@ impl ManagementApi { } } -impl<T> Response<T> { +impl<T: Debug> Response<T> { pub fn unwrap_data(self) -> T { match self { Response::Data { data } => data, - Response::Error { error, details } => { - panic!("Expected data, found error {error:?}: {details:?}") + Response::Error { + error, + details, + reason, + .. + } => { + panic!("Expected data, found error {error:?}: {details:?} {reason:?}") } Response::RequestError(err) => { panic!("Expected data, found error {err:?}") @@ -857,8 +898,13 @@ impl<T> Response<T> { match self { Response::Data { data } => Some(data), Response::RequestError(error) if error.status == 404 => None, - Response::Error { error, details } => { - panic!("Expected data, found error {error:?}: {details:?}") + Response::Error { + error, + details, + reason, + .. + } => { + panic!("Expected data, found error {error:?}: {details:?} {reason:?}") } Response::RequestError(err) => { panic!("Expected data, found error {err:?}") @@ -866,13 +912,50 @@ impl<T> Response<T> { } } - pub fn unwrap_error(self) -> (String, String) { + pub fn unwrap_error(self) -> (String, Option<String>, Option<String>) { match self { - Response::Error { error, details } => (error, details), - Response::Data { .. } => panic!("Expected error, found data."), + Response::Error { + error, + details, + reason, + .. + } => (error, details, reason), + Response::Data { data } => panic!("Expected error, found data: {data:?}"), Response::RequestError(err) => { panic!("Expected error, found request error {err:?}") } } } + + pub fn unwrap_request_error(self) -> RequestError<'static> { + match self { + Response::Error { + error, + details, + reason, + .. + } => { + panic!("Expected request error, found error {error:?}: {details:?} {reason:?}") + } + Response::Data { data } => panic!("Expected request error, found data: {data:?}"), + Response::RequestError(err) => err, + } + } + + pub fn expect_request_error(self, value: &str) { + let err = self.unwrap_request_error(); + if !err.detail.contains(value) && !err.title.as_ref().map_or(false, |t| t.contains(value)) { + panic!("Expected request error containing {value:?}, found {err:?}") + } + } + + pub fn expect_error(self, value: &str) { + let (error, details, reason) = self.unwrap_error(); + if !error.contains(value) + && !details.as_ref().map_or(false, |d| d.contains(value)) + && !reason.as_ref().map_or(false, |r| r.contains(value)) + { + panic!("Expected error containing {value:?}, found {error:?}: {details:?} {reason:?}") + } + } } diff --git a/tests/src/jmap/permissions.rs b/tests/src/jmap/permissions.rs new file mode 100644 index 00000000..236fc153 --- /dev/null +++ b/tests/src/jmap/permissions.rs @@ -0,0 +1,862 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art> + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashSet; +use common::{ + auth::{AccessToken, TenantInfo}, + DeliveryResult, IngestMessage, +}; +use directory::{ + backend::internal::{PrincipalField, PrincipalUpdate, PrincipalValue}, + Permission, Principal, Type, +}; +use hyper::header::TE; +use rayon::vec; +use utils::BlobHash; + +use crate::jmap::assert_is_empty; + +use super::{enterprise::List, JMAPTest, ManagementApi}; + +pub async fn test(params: &JMAPTest) { + let core = params.server.core.clone(); + let server = params.server.clone(); + + // Prepare management API + let api = ManagementApi::new(8899, "admin", "secret"); + + // Create a user with the default 'user' role + let account_id = api + .post::<u32>( + "/api/principal", + &Principal::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(); + core.get_access_token(account_id) + .await + .unwrap() + .validate_permissions( + Permission::all().filter(|p| p.is_user_permission() && *p != Permission::Pop3Dele), + ); + + // 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::<u32>( + "/api/principal", + &Principal::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::<Vec<_>>(), + ) + .with_field( + PrincipalField::Roles, + parent_role + .iter() + .map(|r| r.to_string()) + .collect::<Vec<_>>(), + ), + ) + .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(); + core.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, + ]); + + // Query all principals + api.get::<List<Principal>>("/api/principal") + .await + .unwrap() + .unwrap_data() + .assert_count(6) + .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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::new(u32::MAX, Type::Domain).with_field(PrincipalField::Name, "example.org"), + ) + .await + .unwrap() + .unwrap_data(); + + // Create tenant admin + let tenant_admin_id = api + .post::<u32>( + "/api/principal", + &Principal::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 + core.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::<u32>( + "/api/principal", + &Principal::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_error("notFound"); + tenant_api + .get::<()>("/api/principal/foobar") + .await + .unwrap() + .expect_error("notFound"); + tenant_api + .get::<()>("/api/principal?type=tenant") + .await + .unwrap() + .expect_request_error("Forbidden"); + + // Create a second domain for the tenant + tenant_api + .post::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::new(u32::MAX, Type::Individual) + .with_field(PrincipalField::Name, "john@foobar.org") + .with_field(PrincipalField::Roles, vec!["admin".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 + core.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::<u32>( + "/api/principal", + &Principal::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::<u32>( + "/api/principal", + &Principal::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 + core.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::<List<Principal>>("/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, + &["admin", "no-mail-for-you@foobar.com"][..], + ), + (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 = BlobHash::from(TEST_MESSAGE.as_bytes()); + core.storage + .blob + .put_blob(message_blob.as_ref(), TEST_MESSAGE.as_bytes()) + .await + .unwrap(); + assert_eq!( + server + .deliver_message(IngestMessage { + sender_address: "bill@foobar.org".to_string(), + recipients: vec!["john@foobar.org".to_string()], + message_blob: message_blob.clone(), + message_size: TEST_MESSAGE.len(), + session_id: 0, + }) + .await, + vec![DeliveryResult::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(); + core.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(), + recipients: vec!["john@foobar.org".to_string()], + message_blob: message_blob.clone(), + message_size: TEST_MESSAGE.len(), + session_id: 0, + }) + .await, + vec![DeliveryResult::Success] + ); + + // Quota for the tenant and user should be updated + assert_eq!( + server.get_used_quota(tenant_id).await.unwrap(), + TEST_MESSAGE.len() as i64 + ); + assert_eq!( + server.get_used_quota(tenant_user_id).await.unwrap(), + TEST_MESSAGE.len() as i64 + ); + + // Next delivery should fail due to tenant quota + assert_eq!( + server + .deliver_message(IngestMessage { + sender_address: "bill@foobar.org".to_string(), + recipients: vec!["john@foobar.org".to_string()], + message_blob, + message_size: TEST_MESSAGE.len(), + session_id: 0, + }) + .await, + vec![DeliveryResult::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 + ); + + // 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/foobar.org", + "/api/principal/foobar.com", + "/api/principal/admin@foobar.org", + ] { + tenant_api.delete::<()>(query).await.unwrap().unwrap_data(); + } + + // Delete tenant + api.delete::<()>("/api/principal/foobar") + .await + .unwrap() + .unwrap_data(); + + assert_is_empty(server).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<Item = (PrincipalField, &'x [&'x str])>, + ) -> Self; + fn assert_count(self, count: usize) -> Self; +} + +impl ValidatePrincipalList for List<Principal> { + fn assert_exists<'x>( + self, + name: &str, + typ: Type, + items: impl IntoIterator<Item = (PrincipalField, &'x [&'x str])>, + ) -> 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<Item = (PrincipalField, &'x [&'x str])>, + ); +} + +impl ValidatePrincipal for Principal { + fn validate<'x>( + &self, + typ: Type, + items: impl IntoIterator<Item = (PrincipalField, &'x [&'x str])>, + ) { + 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::<AHashSet<_>>(), + expected.iter().copied().collect::<AHashSet<_>>(), + "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<Item = Permission>, + ) -> Self; + fn validate_tenant(self, tenant_id: u32, tenant_quota: u64) -> Self; +} + +impl ValidatePermissions for AccessToken { + fn validate_permissions( + self, + expected_permissions: impl IntoIterator<Item = Permission>, + ) -> 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::<AHashSet<_>>(), + 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/smtp/management/queue.rs b/tests/src/smtp/management/queue.rs index cfc4d12a..181713fb 100644 --- a/tests/src/smtp/management/queue.rs +++ b/tests/src/smtp/management/queue.rs @@ -56,7 +56,7 @@ reject-non-fqdn = false relay = true "#; -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] #[allow(dead_code)] pub(super) struct List<T> { pub items: Vec<T>, |