summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormdecimus <mauro@stalw.art>2024-09-18 18:08:57 +0200
committermdecimus <mauro@stalw.art>2024-09-18 18:08:57 +0200
commite9d12aea44930d5136a67fc9536d55d31d9a1ddc (patch)
tree7f6cb7fdee263ea3d2fdb0ec7eae321cc3de12b0
parentd0303aefa8f5c0e8700326f979af17669cb8d325 (diff)
Permissions & multi-tenancy test suite
-rw-r--r--crates/common/src/auth/mod.rs2
-rw-r--r--crates/common/src/lib.rs2
-rw-r--r--crates/directory/src/backend/internal/manage.rs145
-rw-r--r--crates/directory/src/core/principal.rs87
-rw-r--r--crates/jmap/src/api/http.rs16
-rw-r--r--crates/jmap/src/api/management/mod.rs22
-rw-r--r--crates/jmap/src/api/management/principal.rs32
-rw-r--r--crates/trc/src/event/level.rs2
-rw-r--r--tests/src/jmap/auth_oauth.rs2
-rw-r--r--tests/src/jmap/mod.rs111
-rw-r--r--tests/src/jmap/permissions.rs862
-rw-r--r--tests/src/smtp/management/queue.rs2
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(&params).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>,