From ce836216a474f0dde820d896e88d8c86bef39da3 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Mon, 20 Oct 2025 17:54:05 +0200 Subject: [PATCH] Object quotas implementation --- crates/common/src/auth/access_token.rs | 89 ++-- crates/common/src/auth/mod.rs | 1 + crates/common/src/auth/oauth/token.rs | 11 +- crates/common/src/auth/roles.rs | 9 +- crates/common/src/config/jmap/capabilities.rs | 4 +- crates/common/src/config/jmap/settings.rs | 43 +- crates/common/src/core.rs | 4 +- crates/common/src/enterprise/mod.rs | 2 +- crates/dav/src/principal/propfind.rs | 22 +- .../directory/src/backend/internal/lookup.rs | 52 +- .../directory/src/backend/internal/manage.rs | 462 ++++++++++-------- crates/directory/src/backend/ldap/lookup.rs | 55 ++- crates/directory/src/backend/memory/config.rs | 30 +- crates/directory/src/backend/memory/lookup.rs | 2 +- crates/directory/src/backend/oidc/lookup.rs | 14 +- crates/directory/src/backend/sql/lookup.rs | 52 +- crates/directory/src/core/principal.rs | 460 ++++++++++------- crates/directory/src/core/secret.rs | 4 +- crates/directory/src/lib.rs | 52 +- crates/email/src/sieve/ingest.rs | 2 +- crates/http/src/auth/oauth/registration.rs | 2 +- crates/http/src/autoconfig/mod.rs | 4 +- crates/http/src/management/principal.rs | 2 +- crates/jmap/src/api/request.rs | 6 +- crates/jmap/src/identity/get.rs | 19 +- crates/jmap/src/identity/set.rs | 20 +- crates/jmap/src/mailbox/set.rs | 12 + crates/jmap/src/participant_identity/get.rs | 18 +- crates/jmap/src/participant_identity/set.rs | 25 +- crates/jmap/src/principal/get.rs | 8 +- crates/jmap/src/push/set.rs | 8 +- crates/jmap/src/sieve/set.rs | 2 +- crates/managesieve/src/op/putscript.rs | 2 +- crates/migration/src/principal.rs | 70 ++- crates/types/src/collection.rs | 35 +- tests/src/directory/mod.rs | 14 +- tests/src/directory/oidc.rs | 2 +- 37 files changed, 967 insertions(+), 652 deletions(-) diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 7fbd5018..e2789fbc 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -38,24 +38,46 @@ pub enum PrincipalOrId { impl Server { async fn build_access_token_from_principal( &self, - mut principal: Principal, + principal: Principal, revision: u64, ) -> trc::Result { let mut role_permissions = RolePermissions::default(); - // Apply role permissions - for role_id in principal.roles() { - role_permissions.union(self.get_role_permissions(*role_id).await?.as_ref()); - } - - // Add principal permissions - for permission in principal.permissions() { - if permission.grant { - role_permissions.enabled.set(permission.permission as usize); - } else { - role_permissions - .disabled - .set(permission.permission as usize); + // Extract data + let mut object_quota = self.core.jmap.max_objects; + let mut description = None; + let mut tenant_id = None; + let mut quota = None; + let mut locale = None; + let mut member_of = Vec::new(); + let mut emails = Vec::new(); + for data in principal.data { + match data { + PrincipalData::Tenant(v) => tenant_id = Some(v), + PrincipalData::MemberOf(v) => member_of.push(v), + PrincipalData::Role(v) => { + role_permissions.union(self.get_role_permissions(v).await?.as_ref()); + } + PrincipalData::Permission { + permission_id, + grant, + } => { + if grant { + role_permissions.enabled.set(permission_id as usize); + } else { + role_permissions.disabled.set(permission_id as usize); + } + } + PrincipalData::DiskQuota(v) => quota = Some(v), + PrincipalData::ObjectQuota { quota, typ } => { + object_quota[typ as usize] = quota; + } + PrincipalData::Description(v) => description = Some(v), + PrincipalData::Email(v) => { + emails.push(v); + } + PrincipalData::Locale(v) => locale = Some(v), + _ => (), } } @@ -69,7 +91,7 @@ impl Server { #[cfg(feature = "enterprise")] if self.is_enterprise_edition() - && let Some(tenant_id) = principal.tenant + && let Some(tenant_id) = tenant_id { // Limit tenant permissions @@ -91,7 +113,7 @@ impl Server { .id(tenant_id) .caused_by(trc::location!()) })? - .quota + .quota() .unwrap_or_default(), }); } @@ -99,12 +121,6 @@ impl Server { // SPDX-SnippetEnd // Build member of and e-mail addresses - let primary_id = principal.id(); - let member_of = principal - .member_of_mut() - .map(std::mem::take) - .unwrap_or_default(); - let mut emails = principal.emails; for &group_id in &member_of { if let Some(group) = self .store() @@ -113,28 +129,29 @@ impl Server { .caused_by(trc::location!())? && group.typ == Type::Group { - emails.extend(group.emails); + emails.extend(group.data.into_iter().filter_map(|data| { + if let PrincipalData::Email(email) = data { + Some(email) + } else { + None + } + })); } } // Build access token let mut access_token = AccessToken { - primary_id, + primary_id: principal.id, member_of, access_to: VecMap::new(), tenant, name: principal.name, - description: principal.description, + description, emails, - quota: principal.quota.unwrap_or_default(), - locale: principal.data.iter().find_map(|data| { - if let PrincipalData::Locale(v) = data { - Some(v.to_string()) - } else { - None - } - }), + quota: quota.unwrap_or_default(), + locale, permissions, + object_quota, concurrent_imap_requests: self.core.imap.rate_concurrent.map(ConcurrencyLimiter::new), concurrent_http_requests: self .core @@ -492,6 +509,11 @@ impl AccessToken { permissions } + #[inline(always)] + pub fn object_quota(&self, collection: Collection) -> u32 { + self.object_quota[collection as usize] + } + pub fn is_shared(&self, account_id: u32) -> bool { !self.is_member(account_id) && self.access_to.iter().any(|(id, _)| *id == account_id) } @@ -552,6 +574,7 @@ impl AccessToken { + (self.access_to.len() * (std::mem::size_of::() + std::mem::size_of::())) + self.name.len() + self.description.as_ref().map_or(0, |v| v.len()) + + self.locale.as_ref().map_or(0, |v| v.len()) + self.emails.iter().map(|v| v.len()).sum::()) as u64; self } diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index e7b68556..5ab4b39e 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -34,6 +34,7 @@ pub struct AccessToken { pub locale: Option, pub emails: Vec, pub quota: u64, + pub object_quota: [u32; Collection::MAX], pub permissions: Permissions, pub tenant: Option, pub concurrent_http_requests: Option, diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index d680f267..51247a96 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -6,7 +6,7 @@ use super::{CLIENT_ID_MAX_LEN, GrantType, RANDOM_CODE_LEN, crypto::SymmetricEncrypt}; use crate::Server; -use directory::QueryParams; +use directory::{PrincipalData, QueryParams}; use mail_builder::encoders::base64::base64_encode; use mail_parser::decoders::base64::base64_decode; use std::time::SystemTime; @@ -228,8 +228,15 @@ impl Server { .into_err() .details("Account no longer exists") })? - .secrets + .data .into_iter() + .filter_map(|v| { + if let PrincipalData::Secret(secret) = v { + Some(secret) + } else { + None + } + }) .next() .ok_or( trc::AuthEvent::Error diff --git a/crates/common/src/auth/roles.rs b/crates/common/src/auth/roles.rs index 6c3a9c6a..da5433a7 100644 --- a/crates/common/src/auth/roles.rs +++ b/crates/common/src/auth/roles.rs @@ -93,7 +93,7 @@ impl Server { let mut role_permissions = RolePermissions::default(); // Obtain principal - let mut principal = self + let principal = self .store() .query(QueryParams::id(role_id).with_return_member_of(true)) .await @@ -122,11 +122,10 @@ impl Server { return_permissions.union(&role_permissions); // Add parent roles - if let Some(parent_role_ids) = - principal.roles_mut().filter(|r| !r.is_empty()) - { + let mut principal_roles = principal.roles().peekable(); + if principal_roles.peek().is_some() { role_ids_stack.push(role_ids); - role_ids = std::mem::take(parent_role_ids).into_iter(); + role_ids = principal_roles.collect::>().into_iter(); } else { // Cache role self.inner diff --git a/crates/common/src/config/jmap/capabilities.rs b/crates/common/src/config/jmap/capabilities.rs index e4dae1a3..bdb7f5e4 100644 --- a/crates/common/src/config/jmap/capabilities.rs +++ b/crates/common/src/config/jmap/capabilities.rs @@ -19,7 +19,7 @@ use jmap_proto::{ }, types::date::UTCDate, }; -use types::type_state::DataType; +use types::{collection::Collection, type_state::DataType}; use utils::{config::Config, map::vec_map::VecMap}; impl JmapConfig { @@ -221,7 +221,7 @@ impl JmapConfig { max_script_size: config .property("sieve.untrusted.max-script-size") .unwrap_or(1024 * 1024), - max_scripts: self.sieve_max_scripts, + max_scripts: self.max_objects[Collection::SieveScript as usize] as usize, max_redirects: config .property("sieve.untrusted.max-redirects") .unwrap_or(1), diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index 967b9634..4f430db8 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -8,8 +8,11 @@ use crate::config::groupware::GroupwareConfig; use jmap_proto::request::capability::BaseCapabilities; use nlp::language::Language; use std::{str::FromStr, time::Duration}; -use types::special_use::SpecialUse; -use utils::config::{Config, Rate, cron::SimpleCron, utils::ParseValue}; +use types::{collection::Collection, special_use::SpecialUse}; +use utils::{ + config::{Config, Rate, cron::SimpleCron, utils::ParseValue}, + map::bitmap::Bitmap, +}; #[derive(Default, Clone)] pub struct JmapConfig { @@ -46,13 +49,12 @@ pub struct JmapConfig { pub calendar_parse_max_items: usize, pub sieve_max_script_name: usize, - pub sieve_max_scripts: usize, + pub max_objects: [u32; Collection::MAX], pub rate_authenticated: Option, pub rate_anonymous: Option, pub event_source_throttle: Duration, - pub push_max_total: usize, pub push_attempt_interval: Duration, pub push_attempts_max: u32, pub push_retry_interval: Duration, @@ -279,9 +281,7 @@ impl JmapConfig { sieve_max_script_name: config .property("sieve.untrusted.limits.name-length") .unwrap_or(512), - sieve_max_scripts: config - .property("sieve.untrusted.limits.max-scripts") - .unwrap_or(256), + max_objects: [u32::MAX; Collection::MAX], capabilities: BaseCapabilities::default(), rate_authenticated: config .property_or_default::>("http.rate-limit.account", "1000/1m") @@ -301,9 +301,6 @@ impl JmapConfig { web_socket_heartbeat: config .property_or_default("jmap.web-socket.heartbeat", "1m") .unwrap_or_else(|| Duration::from_secs(60)), - push_max_total: config - .property_or_default("jmap.push.max-total", "100") - .unwrap_or(100), encrypt: config .property_or_default("email.encryption.enable", "true") .unwrap_or(true), @@ -355,6 +352,32 @@ impl JmapConfig { shared_folder, }; + for collection in Bitmap::::all() { + let key = format!("object-quota.{}", collection.as_config_case()); + jmap.max_objects[collection as usize] = + if let Some(value) = config.property::(&key) { + value + } else { + match collection { + Collection::Mailbox => 250, + Collection::SieveScript => 100, + Collection::Identity => 20, + Collection::EmailSubmission => 500, + Collection::PushSubscription => 15, + Collection::Calendar => 250, + Collection::AddressBook => 250, + Collection::Principal + | Collection::None + | Collection::CalendarEventNotification + | Collection::CalendarEvent + | Collection::ContactCard + | Collection::FileNode + | Collection::Email + | Collection::Thread => u32::MAX, + } + }; + } + // Add capabilities jmap.add_capabilities(config, groupware_config); jmap diff --git a/crates/common/src/core.rs b/crates/common/src/core.rs index 58e91fe1..41a11a85 100644 --- a/crates/common/src/core.rs +++ b/crates/common/src/core.rs @@ -455,7 +455,7 @@ impl Server { .await .add_context(|err| err.caused_by(trc::location!()).account_id(account_id))? { - quotas.quota = principal.quota(); + quotas.quota = principal.quota().unwrap_or_default(); // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC @@ -476,7 +476,7 @@ impl Server { .add_context(|err| { err.caused_by(trc::location!()).account_id(tenant_id) })? - .map(|tenant| tenant.quota()) + .and_then(|tenant| tenant.quota()) .unwrap_or_default(), } .into(); diff --git a/crates/common/src/enterprise/mod.rs b/crates/common/src/enterprise/mod.rs index 71487e51..20e735b6 100644 --- a/crates/common/src/enterprise/mod.rs +++ b/crates/common/src/enterprise/mod.rs @@ -170,7 +170,7 @@ impl Server { { if let Some(logo) = principal.picture_mut().filter(|l| l.starts_with("http")) { std::mem::take(logo).into() - } else if let Some(tenant_id) = principal.tenant { + } else if let Some(tenant_id) = principal.tenant() { if let Some(logo) = self .store() .query(QueryParams::id(tenant_id).with_return_member_of(false)) diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs index 07e728cc..d749fb21 100644 --- a/crates/dav/src/principal/propfind.rs +++ b/crates/dav/src/principal/propfind.rs @@ -19,7 +19,7 @@ use dav_proto::schema::{ request::{DavPropertyValue, PropFind}, response::{Href, MultiStatus, PropStat, Response}, }; -use directory::{QueryParams, Type, backend::internal::manage::ManageDirectory}; +use directory::{PrincipalData, QueryParams, Type, backend::internal::manage::ManageDirectory}; use groupware::RFC_3986; use groupware::cache::GroupwareCache; use hyper::StatusCode; @@ -124,11 +124,25 @@ impl PrincipalPropFind for Server { .caused_by(trc::location!())? .map(|p| { let name = p.name; - let description = p.description.unwrap_or_else(|| name.clone()); + let mut description = None; + let mut emails = Vec::new(); + for data in p.data { + match data { + PrincipalData::Description(desc) => { + description = Some(desc); + } + PrincipalData::Email(email) => { + emails.push(email); + } + _ => {} + } + } + + let description = description.unwrap_or_else(|| name.clone()); ( Cow::Owned(name.to_string()), - description.to_string(), - Cow::Owned(p.emails), + description, + Cow::Owned(emails), p.typ, ) }) diff --git a/crates/directory/src/backend/internal/lookup.rs b/crates/directory/src/backend/internal/lookup.rs index c8e1ec41..8f781ce5 100644 --- a/crates/directory/src/backend/internal/lookup.rs +++ b/crates/directory/src/backend/internal/lookup.rs @@ -56,27 +56,19 @@ impl DirectoryStore for Store { } if by.return_member_of { - let mut roles = vec![]; - let mut lists = vec![]; - let mut member_of = vec![]; - for member in self.get_member_of(principal.id).await? { match member.typ { - Type::List => lists.push(member.principal_id), - Type::Role => roles.push(member.principal_id), - _ => member_of.push(member.principal_id), + Type::List => principal + .data + .push(PrincipalData::List(member.principal_id)), + Type::Role => principal + .data + .push(PrincipalData::Role(member.principal_id)), + _ => principal + .data + .push(PrincipalData::MemberOf(member.principal_id)), } } - - if !roles.is_empty() { - principal.data.push(PrincipalData::Roles(roles)); - } - if !lists.is_empty() { - principal.data.push(PrincipalData::Lists(lists)); - } - if !member_of.is_empty() { - principal.data.push(PrincipalData::MemberOf(member_of)); - } } return Ok(Some(principal)); } @@ -165,25 +157,27 @@ impl DirectoryStore for Store { async fn expn_by_id(&self, list_id: u32) -> trc::Result> { let mut results = Vec::new(); for account_id in self.get_members(list_id).await? { - if let Some(email) = self - .get_principal(account_id) - .await? - .and_then(|p| p.emails.into_iter().next()) - { + if let Some(email) = self.get_principal(account_id).await?.and_then(|p| { + p.data.into_iter().find_map(|data| { + if let PrincipalData::Email(email) = data { + Some(email) + } else { + None + } + }) + }) { results.push(email); } } - if let Some(emails) = self.get_principal(list_id).await?.and_then(|p| { - p.data.into_iter().find_map(|data| { - if let PrincipalData::ExternalMembers(members) = data { - Some(members) + if let Some(principal) = self.get_principal(list_id).await? { + results.extend(principal.data.into_iter().filter_map(|data| { + if let PrincipalData::ExternalMember(member) = data { + Some(member) } else { None } - }) - }) { - results.extend(emails); + })); } Ok(results) diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index 4545611b..ab5d2a04 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -9,9 +9,9 @@ use super::{ SpecialSecrets, lookup::DirectoryStore, }; use crate::{ - FALLBACK_ADMIN_ID, MemberOf, Permission, PermissionGrant, Permissions, Principal, - PrincipalData, PrincipalQuota, QueryBy, QueryParams, ROLE_ADMIN, ROLE_TENANT_ADMIN, ROLE_USER, - Type, backend::RcptType, core::principal::build_search_index, + ArchivedPrincipalData, FALLBACK_ADMIN_ID, MemberOf, Permission, PermissionGrant, Permissions, + Principal, PrincipalData, QueryBy, QueryParams, ROLE_ADMIN, ROLE_TENANT_ADMIN, ROLE_USER, Type, + backend::RcptType, core::principal::build_search_index, }; use ahash::{AHashMap, AHashSet}; use compact_str::CompactString; @@ -286,14 +286,14 @@ impl ManageDirectory for Store { // Enforce tenant quotas if let Some(limit) = tenant - .principal_quota(&principal_set.typ()) + .directory_quota(&principal_set.typ()) .filter(|q| *q > 0) { // Obtain number of principals let total = self .count_principals(None, principal_set.typ().into(), tenant_id.into()) .await - .caused_by(trc::location!())?; + .caused_by(trc::location!())? as u32; if total >= limit { trc::bail!( @@ -351,7 +351,7 @@ impl ManageDirectory for Store { )); } - principal_create.tenant = tenant_id.into(); + principal_create.data.push(PrincipalData::Tenant(tenant_id)); if !matches!(principal_create.typ, Type::Tenant | Type::Domain) { if let Some(domain) = name.try_domain_part() @@ -377,45 +377,51 @@ impl ManageDirectory for Store { // Set fields principal_create.name = name; - principal_create.description = principal_set.take_str(PrincipalField::Description); - principal_create.secrets = principal_set + if let Some(description) = principal_set.take_str(PrincipalField::Description) { + principal_create + .data + .push(PrincipalData::Description(description)); + } + + for secret in principal_set .take_str_array(PrincipalField::Secrets) - .unwrap_or_default(); + .unwrap_or_default() + { + principal_create.data.push(PrincipalData::Secret(secret)); + } if let Some(picture) = principal_set.take_str(PrincipalField::Picture) { principal_create.data.push(PrincipalData::Picture(picture)); } if let Some(picture) = principal_set.take_str(PrincipalField::Locale) { principal_create.data.push(PrincipalData::Locale(picture)); } - if let Some(urls) = principal_set.take_str_array(PrincipalField::Urls) { - principal_create.data.push(PrincipalData::Urls(urls)); + for url in principal_set + .take_str_array(PrincipalField::Urls) + .unwrap_or_default() + { + principal_create.data.push(PrincipalData::Url(url)); } - if let Some(urls) = principal_set.take_str_array(PrincipalField::ExternalMembers) { + for member in principal_set + .take_str_array(PrincipalField::ExternalMembers) + .unwrap_or_default() + { principal_create .data - .push(PrincipalData::ExternalMembers(urls)); + .push(PrincipalData::ExternalMember(member)); } if let Some(quotas) = principal_set.take_int_array(PrincipalField::Quota) { - let mut principal_quotas = Vec::new(); - for (idx, quota) in quotas.into_iter().take(Type::MAX_ID + 2).enumerate() { if quota != 0 { if idx != 0 { - principal_quotas.push(PrincipalQuota { - quota, + principal_create.data.push(PrincipalData::DirectoryQuota { + quota: quota as u32, typ: Type::from_u8((idx - 1) as u8), }); } else { - principal_create.quota = Some(quota); + principal_create.data.push(PrincipalData::DiskQuota(quota)); } } } - - if !principal_quotas.is_empty() { - principal_create - .data - .push(PrincipalData::PrincipalQuota(principal_quotas)); - } } // Map member names @@ -502,15 +508,12 @@ impl ManageDirectory for Store { } } if !permissions.is_empty() { - principal_create.data.push(PrincipalData::Permissions( - permissions - .into_iter() - .map(|(k, v)| PermissionGrant { - permission: k.id(), - grant: !v, - }) - .collect(), - )); + for (permission, v) in permissions { + principal_create.data.push(PrincipalData::Permission { + permission_id: permission.id(), + grant: !v, + }); + } } // Make sure the e-mail is not taken and validate domain @@ -532,7 +535,7 @@ impl ManageDirectory for Store { .filter(|v| v.typ == Type::Domain && v.has_tenant_access(tenant_id)) .ok_or_else(|| not_found(domain.to_string()))?; } - principal_create.emails.push(email); + principal_create.data.push(PrincipalData::Email(email)); } } @@ -589,7 +592,7 @@ impl ManageDirectory for Store { ); // Write email to id mapping - for email in principal_create.emails { + for email in principal_create.emails() { batch.set( ValueClass::Directory(DirectoryClass::EmailToId(email.as_bytes().to_vec())), pinfo_email.serialize(), @@ -665,7 +668,13 @@ impl ManageDirectory for Store { let mut batch = BatchBuilder::new(); batch.with_account_id(u32::MAX); - let tenant = principal.tenant.as_ref().map(|t| t.to_native()); + let tenant = principal.data.iter().find_map(|data| { + if let ArchivedPrincipalData::Tenant(tenant_id) = data { + Some(tenant_id.to_native()) + } else { + None + } + }); // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC @@ -820,8 +829,10 @@ impl ManageDirectory for Store { .clear(DirectoryClass::Principal(principal_id)) .clear(DirectoryClass::UsedQuota(principal_id)); - for email in principal.emails.iter() { - batch.clear(DirectoryClass::EmailToId(email.as_bytes().to_vec())); + for email in principal.data.iter() { + if let ArchivedPrincipalData::Email(email) = email { + batch.clear(DirectoryClass::EmailToId(email.as_bytes().to_vec())); + } } build_search_index(&mut batch, principal_id, Some(principal), None); @@ -1095,7 +1106,10 @@ impl ManageDirectory for Store { // Tenant changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - principal.tenant = tenant_info.id.into(); + principal + .data + .retain(|v| !matches!(v, PrincipalData::Tenant(_))); + principal.data.push(PrincipalData::Tenant(tenant_info.id)); pinfo_name = PrincipalInfo::new(principal_id, principal_type, tenant_info.id.into()) .serialize(); @@ -1108,7 +1122,9 @@ impl ManageDirectory for Store { // Tenant changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - principal.tenant = None; + principal + .data + .retain(|v| !matches!(v, PrincipalData::Tenant(_))); pinfo_name = PrincipalInfo::new(principal_id, principal_type, None).serialize(); } else { @@ -1131,18 +1147,22 @@ impl ManageDirectory for Store { ) => { // Password changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - - principal.secrets = value.into_str_array(); + principal + .data + .retain(|v| !matches!(v, PrincipalData::Secret(_))); + for secret in value.into_str_array() { + principal.data.push(PrincipalData::Secret(secret)); + } } ( PrincipalAction::AddItem, PrincipalField::Secrets, PrincipalValue::String(secret), ) => { - if !principal.secrets.contains(&secret) { + if !principal.secrets().any(|v| *v == secret) { if secret.is_otp_auth() { // Add OTP Auth URLs to the beginning of the list - principal.secrets.insert(0, secret); + principal.data.insert(0, PrincipalData::Secret(secret)); // Password changed, update changed principals changed_principals.add_change( @@ -1151,7 +1171,7 @@ impl ManageDirectory for Store { change.field, ); } else { - principal.secrets.push(secret); + principal.data.push(PrincipalData::Secret(secret)); // Password changed, update changed principals changed_principals.add_change( principal_id, @@ -1170,13 +1190,22 @@ impl ManageDirectory for Store { changed_principals.add_change(principal_id, principal_type, change.field); if secret.is_app_password() || secret.is_otp_auth() { - principal - .secrets - .retain(|v| *v != secret && !v.starts_with(secret.as_str())); + principal.data.retain(|v| match v { + PrincipalData::Secret(v) => { + *v != secret && !v.starts_with(secret.as_str()) + } + _ => true, + }); } else if !secret.is_empty() { - principal.secrets.retain(|v| *v != secret); + principal.data.retain(|v| match v { + PrincipalData::Secret(v) => *v != secret, + _ => true, + }); } else { - principal.secrets.retain(|v| !v.is_password()); + principal.data.retain(|v| match v { + PrincipalData::Secret(v) => !v.is_password(), + _ => true, + }); } } ( @@ -1184,10 +1213,11 @@ impl ManageDirectory for Store { PrincipalField::Description, PrincipalValue::String(value), ) => { + principal + .data + .retain(|v| !matches!(v, PrincipalData::Description(_))); if !value.is_empty() { - principal.description = Some(value); - } else { - principal.description = None; + principal.data.push(PrincipalData::Description(value)); } } (PrincipalAction::Set, PrincipalField::Picture, PrincipalValue::String(value)) => { @@ -1214,7 +1244,10 @@ impl ManageDirectory for Store { { // Quota changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - principal.quota = Some(quota); + principal + .data + .retain(|v| !matches!(v, PrincipalData::DiskQuota(_))); + principal.data.push(PrincipalData::DiskQuota(quota)); } (PrincipalAction::Set, PrincipalField::Quota, PrincipalValue::String(quota)) if matches!( @@ -1224,7 +1257,9 @@ impl ManageDirectory for Store { { // Quota changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - principal.quota = None; + principal + .data + .retain(|v| !matches!(v, PrincipalData::DiskQuota(_))); } ( PrincipalAction::Set, @@ -1234,13 +1269,19 @@ impl ManageDirectory for Store { && quotas.len() <= (Type::MAX_ID + 2) => { let mut new_quota = None; - let mut principal_quotas = Vec::new(); + + principal.data.retain(|v| { + !matches!( + v, + PrincipalData::DiskQuota(_) | PrincipalData::DirectoryQuota { .. } + ) + }); for (idx, quota) in quotas.into_iter().enumerate() { if quota != 0 { if idx != 0 { - principal_quotas.push(PrincipalQuota { - quota, + principal.data.push(PrincipalData::DirectoryQuota { + quota: quota as u32, typ: Type::from_u8((idx - 1) as u8), }); } else { @@ -1249,14 +1290,8 @@ impl ManageDirectory for Store { } } - principal.quota = new_quota; - principal - .data - .retain(|v| !matches!(v, PrincipalData::PrincipalQuota(_))); - if !principal_quotas.is_empty() { - principal - .data - .push(PrincipalData::PrincipalQuota(principal_quotas)); + if let Some(new_quota) = new_quota { + principal.data.push(PrincipalData::DiskQuota(new_quota)); } } @@ -1272,7 +1307,7 @@ impl ManageDirectory for Store { .map(|v| v.to_lowercase()) .collect::>(); for email in &emails { - if !principal.emails.contains(email) { + if !principal.emails().any(|v| v == email) { if validate_emails { self.validate_email(email, tenant_id, params.create_domains) .await?; @@ -1286,7 +1321,7 @@ impl ManageDirectory for Store { } } - for email in &principal.emails { + for email in principal.emails() { if !emails.contains(email) { batch.clear(ValueClass::Directory(DirectoryClass::EmailToId( email.as_bytes().to_vec(), @@ -1297,7 +1332,12 @@ impl ManageDirectory for Store { // Emails changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); - principal.emails = emails; + principal + .data + .retain(|v| !matches!(v, PrincipalData::Email(_))); + for email in emails { + principal.data.push(PrincipalData::Email(email)); + } } ( PrincipalAction::AddItem, @@ -1305,7 +1345,7 @@ impl ManageDirectory for Store { PrincipalValue::String(email), ) => { let email = email.to_lowercase(); - if !principal.emails.contains(&email) { + if !principal.emails().any(|v| v == &email) { if validate_emails { self.validate_email(&email, tenant_id, params.create_domains) .await?; @@ -1316,7 +1356,7 @@ impl ManageDirectory for Store { )), pinfo_email.clone(), ); - principal.emails.push(email); + principal.data.push(PrincipalData::Email(email)); // Emails changed, update changed principals changed_principals.add_change(principal_id, principal_type, change.field); @@ -1328,8 +1368,11 @@ impl ManageDirectory for Store { PrincipalValue::String(email), ) => { let email = email.to_lowercase(); - if let Some(idx) = principal.emails.iter().position(|v| v == &email) { - principal.emails.remove(idx); + if principal.emails().any(|v| v == &email) { + principal.data.retain(|v| match v { + PrincipalData::Email(v) => v != &email, + _ => true, + }); batch.clear(ValueClass::Directory(DirectoryClass::EmailToId( email.as_bytes().to_vec(), ))); @@ -1732,7 +1775,7 @@ impl ManageDirectory for Store { if !permissions.is_empty() { principal.add_permissions(permissions.into_iter().map(|permission| { PermissionGrant { - permission: permission.id(), + permission, grant: !is_disabled, } })); @@ -1800,35 +1843,39 @@ impl ManageDirectory for Store { ) => { principal .data - .retain(|v| !matches!(v, PrincipalData::ExternalMembers(_))); + .retain(|v| !matches!(v, PrincipalData::ExternalMember(_))); if !items.is_empty() { - principal.data.push(PrincipalData::ExternalMembers( + principal.data.extend( items .into_iter() .map(|item| { - sanitize_email(&item).ok_or_else(|| { - error( - "Invalid email address", - format!( - "Invalid value {:?} for {}", - item, - change.field.as_str() + sanitize_email(&item) + .map(PrincipalData::ExternalMember) + .ok_or_else(|| { + error( + "Invalid email address", + format!( + "Invalid value {:?} for {}", + item, + change.field.as_str() + ) + .into(), ) - .into(), - ) - }) + }) }) - .collect::>()?, - )); + .collect::>>()?, + ); } } (PrincipalAction::Set, PrincipalField::Urls, PrincipalValue::StringList(items)) => { principal .data - .retain(|v| !matches!(v, PrincipalData::Urls(_))); + .retain(|v| !matches!(v, PrincipalData::Url(_))); if !items.is_empty() { - principal.data.push(PrincipalData::Urls(items)); + principal + .data + .extend(items.into_iter().map(PrincipalData::Url)); } } ( @@ -1847,24 +1894,22 @@ impl ManageDirectory for Store { } let mut found = false; - for data in &mut principal.data { + for data in &principal.data { match (data, change.field) { - (PrincipalData::Urls(urls), PrincipalField::Urls) => { - if !urls.contains(&item) { - urls.push(item.clone()); + (PrincipalData::Url(url), PrincipalField::Urls) => { + if url == &item { + found = true; + break; } - found = true; - break; } ( - PrincipalData::ExternalMembers(emails), + PrincipalData::ExternalMember(email), PrincipalField::ExternalMembers, ) => { - if !emails.contains(&item) { - emails.push(item.clone()); + if email == &item { + found = true; + break; } - found = true; - break; } _ => {} } @@ -1872,37 +1917,33 @@ impl ManageDirectory for Store { if !found { match change.field { - PrincipalField::Urls => { - principal.data.push(PrincipalData::Urls(vec![item])) + PrincipalField::Urls => principal.data.push(PrincipalData::Url(item)), + PrincipalField::ExternalMembers => { + principal.data.push(PrincipalData::ExternalMember(item)) } - PrincipalField::ExternalMembers => principal - .data - .push(PrincipalData::ExternalMembers(vec![item])), _ => {} } } } ( PrincipalAction::RemoveItem, - PrincipalField::Urls | PrincipalField::ExternalMembers, + PrincipalField::Urls, PrincipalValue::String(item), ) => { - for data in &mut principal.data { - match (data, change.field) { - (PrincipalData::Urls(urls), PrincipalField::Urls) => { - urls.retain(|v| *v != item); - break; - } - ( - PrincipalData::ExternalMembers(emails), - PrincipalField::ExternalMembers, - ) => { - emails.retain(|v| *v != item); - break; - } - _ => {} - } - } + principal.data.retain(|v| match v { + PrincipalData::Url(v) => v != &item, + _ => true, + }); + } + ( + PrincipalAction::RemoveItem, + PrincipalField::ExternalMembers, + PrincipalValue::String(item), + ) => { + principal.data.retain(|v| match v { + PrincipalData::ExternalMember(v) => v != &item, + _ => true, + }); } (_, field, value) => { @@ -2163,105 +2204,123 @@ impl ManageDirectory for Store { let has_enabled = fields.is_empty() || fields.contains(&PrincipalField::EnabledPermissions); let has_disabled = fields.is_empty() || fields.contains(&PrincipalField::DisabledPermissions); - let mut principal_quotas = Vec::new(); + let mut directory_quotas = Vec::new(); + let mut quota = None; + let mut tenant_id = None; for data in principal.data { match data { - PrincipalData::MemberOf(items) + PrincipalData::MemberOf(principal_id) if fields.is_empty() || fields.contains(&PrincipalField::MemberOf) => { - for principal_id in items { - if let Some(name) = self - .get_principal_name(principal_id) - .await - .caused_by(trc::location!())? - { - result.append_str(PrincipalField::MemberOf, name); - } + if let Some(name) = self + .get_principal_name(principal_id) + .await + .caused_by(trc::location!())? + { + result.append_str(PrincipalField::MemberOf, name); } } - PrincipalData::Roles(items) + PrincipalData::Role(principal_id) if fields.is_empty() || fields.contains(&PrincipalField::Roles) => { - for principal_id in items { - match principal_id { - ROLE_ADMIN => { - result.append_str(PrincipalField::Roles, "admin"); - } - ROLE_TENANT_ADMIN => { - result.append_str(PrincipalField::Roles, "tenant-admin"); - } - ROLE_USER => { - result.append_str(PrincipalField::Roles, "user"); - } - principal_id => { - if let Some(name) = self - .get_principal_name(principal_id) - .await - .caused_by(trc::location!())? - { - result.append_str(PrincipalField::Roles, name); - } + match principal_id { + ROLE_ADMIN => { + result.append_str(PrincipalField::Roles, "admin"); + } + ROLE_TENANT_ADMIN => { + result.append_str(PrincipalField::Roles, "tenant-admin"); + } + ROLE_USER => { + result.append_str(PrincipalField::Roles, "user"); + } + principal_id => { + if let Some(name) = self + .get_principal_name(principal_id) + .await + .caused_by(trc::location!())? + { + result.append_str(PrincipalField::Roles, name); } } } } - PrincipalData::Lists(items) + PrincipalData::List(principal_id) if fields.is_empty() || fields.contains(&PrincipalField::Lists) => { - for principal_id in items { - if let Some(name) = self - .get_principal_name(principal_id) - .await - .caused_by(trc::location!())? - { - result.append_str(PrincipalField::Lists, name); - } + if let Some(name) = self + .get_principal_name(principal_id) + .await + .caused_by(trc::location!())? + { + result.append_str(PrincipalField::Lists, name); } } - PrincipalData::Permissions(permission_grants) if has_enabled || has_disabled => { - for grant in permission_grants { - if grant.grant { - if has_enabled { - result.append_str( - PrincipalField::EnabledPermissions, - Permission::from_id(grant.permission) - .map(|f| f.name()) - .unwrap_or("unknown"), - ); - } - } else if has_disabled { + PrincipalData::Permission { + permission_id, + grant, + } if has_enabled || has_disabled => { + if grant { + if has_enabled { result.append_str( - PrincipalField::DisabledPermissions, - Permission::from_id(grant.permission) + PrincipalField::EnabledPermissions, + Permission::from_id(permission_id) .map(|f| f.name()) .unwrap_or("unknown"), ); } + } else if has_disabled { + result.append_str( + PrincipalField::DisabledPermissions, + Permission::from_id(permission_id) + .map(|f| f.name()) + .unwrap_or("unknown"), + ); } } - PrincipalData::Picture(compact_string) => { + PrincipalData::DiskQuota(q) => { + quota = Some(q); + } + PrincipalData::Tenant(tid) => { + tenant_id = Some(tid); + } + PrincipalData::Description(description) => { + if fields.is_empty() || fields.contains(&PrincipalField::Description) { + result.set(PrincipalField::Description, description); + } + } + PrincipalData::Secret(secret) => { + if fields.is_empty() || fields.contains(&PrincipalField::Secrets) { + result.append_str(PrincipalField::Secrets, secret); + } + } + PrincipalData::Email(email) => { + if fields.is_empty() || fields.contains(&PrincipalField::Emails) { + result.append_str(PrincipalField::Emails, email); + } + } + PrincipalData::Picture(picture) => { if fields.is_empty() || fields.contains(&PrincipalField::Picture) { - result.set(PrincipalField::Picture, compact_string); + result.set(PrincipalField::Picture, picture); } } - PrincipalData::Locale(compact_string) => { + PrincipalData::Locale(locale) => { if fields.is_empty() || fields.contains(&PrincipalField::Locale) { - result.set(PrincipalField::Locale, compact_string); + result.set(PrincipalField::Locale, locale); } } - PrincipalData::ExternalMembers(compact_strings) => { + PrincipalData::ExternalMember(member) => { if fields.is_empty() || fields.contains(&PrincipalField::ExternalMembers) { - result.set(PrincipalField::ExternalMembers, compact_strings); + result.append_str(PrincipalField::ExternalMembers, member); } } - PrincipalData::Urls(compact_strings) => { + PrincipalData::Url(url) => { if fields.is_empty() || fields.contains(&PrincipalField::Urls) { - result.set(PrincipalField::Urls, compact_strings); + result.append_str(PrincipalField::Urls, url); } } - PrincipalData::PrincipalQuota(principal_quotas_) => { - principal_quotas = principal_quotas_; + PrincipalData::DirectoryQuota { quota, typ } => { + directory_quotas.push((typ, quota)); } _ => (), } @@ -2339,7 +2398,7 @@ impl ManageDirectory for Store { // Map tenant name #[cfg(feature = "enterprise")] - if let Some(tenant_id) = principal.tenant + if let Some(tenant_id) = tenant_id && (fields.is_empty() || fields.contains(&PrincipalField::Tenant)) && let Some(name) = self .get_principal_name(tenant_id) @@ -2352,36 +2411,21 @@ impl ManageDirectory for Store { // SPDX-SnippetEnd // Map fields - for (name, value) in [ - (PrincipalField::Name, Some(principal.name)), - (PrincipalField::Description, principal.description), - ] { - if let Some(value) = value - && (fields.is_empty() || fields.contains(&name)) - { - result.set(name, value); - } - } - for (name, value) in [ - (PrincipalField::Secrets, principal.secrets), - (PrincipalField::Emails, principal.emails), - ] { - if fields.is_empty() || fields.contains(&name) { - result.set(name, value); - } + if fields.is_empty() || fields.contains(&PrincipalField::Name) { + result.set(PrincipalField::Name, principal.name); } if fields.is_empty() || fields.contains(&PrincipalField::Quota) { - if !principal_quotas.is_empty() { + if !directory_quotas.is_empty() { let mut quotas = vec![0u64; Type::MAX_ID + 2]; - if let Some(quota) = principal.quota { + if let Some(quota) = quota { quotas[0] = quota; } - for quota in principal_quotas { - quotas[(quota.typ as usize) + 1] = quota.quota; + for (typ, quota) in directory_quotas { + quotas[(typ as usize) + 1] = quota as u64; } result.set(PrincipalField::Quota, quotas); - } else if let Some(quota) = principal.quota { + } else if let Some(quota) = quota { result.set(PrincipalField::Quota, quota); } } diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index 2f0acdc7..cdc75263 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -214,7 +214,6 @@ impl LdapDirectory { // Query groups if !member_of.is_empty() && by.return_member_of { - let mut data = Vec::with_capacity(member_of.len()); for mut name in member_of { if name.contains('=') { let (rs, _res) = conn @@ -241,15 +240,16 @@ impl LdapDirectory { } } - data.push( - self.data_store - .get_or_create_principal_id(&name, Type::Group) - .await - .caused_by(trc::location!())?, - ); - } + let account_id = self + .data_store + .get_or_create_principal_id(&name, Type::Group) + .await + .caused_by(trc::location!())?; - external_principal.data.push(PrincipalData::MemberOf(data)); + external_principal + .data + .push(PrincipalData::MemberOf(account_id)); + } } // Obtain account ID if not available @@ -430,6 +430,7 @@ impl LdapMappings { let mut principal = Principal::new(0, Type::Individual); let mut role = ROLE_USER; let mut member_of = vec![]; + let mut description = None; for (attr, value) in entry.attrs { if self.attr_name.contains(&attr) { @@ -437,7 +438,9 @@ impl LdapMappings { principal.name = value.into_iter().next().unwrap_or_default(); } else { for (idx, item) in value.into_iter().enumerate() { - principal.emails.insert(0, item.to_lowercase()); + principal + .data + .insert(0, PrincipalData::Email(item.to_lowercase())); if idx == 0 { principal.name = item; } @@ -445,34 +448,42 @@ impl LdapMappings { } } else if self.attr_secret.contains(&attr) { for item in value { - principal.secrets.push(item); + principal.data.push(PrincipalData::Secret(item)); } } else if self.attr_secret_changed.contains(&attr) { // Create a disabled AppPassword, used to indicate that the password has been changed // but cannot be used for authentication. for item in value { - principal.secrets.push(format!( + principal.data.push(PrincipalData::Secret(format!( "$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes()) - )); + ))); } } else if self.attr_email_address.contains(&attr) { for item in value { - principal.emails.insert(0, item.to_lowercase()); + principal + .data + .insert(0, PrincipalData::Email(item.to_lowercase())); } } else if self.attr_email_alias.contains(&attr) { for item in value { - principal.emails.push(item.to_lowercase()); + principal + .data + .push(PrincipalData::Email(item.to_lowercase())); } } else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) { - if principal.description.is_none() || idx == 0 { - principal.description = value.into_iter().next(); + if (description.is_none() || idx == 0) + && let Some(desc) = value.into_iter().next() + { + description = Some(desc); } } else if self.attr_groups.contains(&attr) { member_of.extend(value); } else if self.attr_quota.contains(&attr) { - if let Ok(quota) = value.into_iter().next().unwrap_or_default().parse::() { - principal.quota = quota.into(); + if let Ok(quota) = value.into_iter().next().unwrap_or_default().parse::() + && quota > 0 + { + principal.data.push(PrincipalData::DiskQuota(quota)); } } else if self.attr_type.contains(&attr) { for value in value { @@ -494,7 +505,11 @@ impl LdapMappings { } } - principal.data.push(PrincipalData::Roles(vec![role])); + if let Some(desc) = description { + principal.data.push(PrincipalData::Description(desc)); + } + + principal.data.push(PrincipalData::Role(role)); LdapResult { dn: entry.dn, diff --git a/crates/directory/src/backend/memory/config.rs b/crates/directory/src/backend/memory/config.rs index 76ea5e65..d29d1d10 100644 --- a/crates/directory/src/backend/memory/config.rs +++ b/crates/directory/src/backend/memory/config.rs @@ -59,14 +59,11 @@ impl MemoryDirectory { // Create principal let mut principal = Principal::new(id, typ); - let mut member_of = Vec::with_capacity(2); - principal - .data - .push(PrincipalData::Roles(vec![if is_superuser { - ROLE_ADMIN - } else { - ROLE_USER - }])); + principal.data.push(PrincipalData::Role(if is_superuser { + ROLE_ADMIN + } else { + ROLE_USER + })); // Obtain group ids for group in config @@ -74,7 +71,7 @@ impl MemoryDirectory { .map(|(_, s)| s.to_string()) .collect::>() { - member_of.push( + principal.data.push(PrincipalData::MemberOf( directory .data_store .get_or_create_principal_id(&group, Type::Group) @@ -89,9 +86,8 @@ impl MemoryDirectory { ) }) .ok()?, - ); + )); } - principal.data.push(PrincipalData::MemberOf(member_of)); // Parse email addresses for (pos, (_, email)) in config @@ -112,7 +108,9 @@ impl MemoryDirectory { directory.domains.insert(domain.to_lowercase()); } - principal.emails.push(email.to_lowercase()); + principal + .data + .push(PrincipalData::Email(email.to_lowercase())); } // Parse mailing lists @@ -131,17 +129,19 @@ impl MemoryDirectory { principal.name = name.as_str().into(); for (_, secret) in config.values((prefix.as_str(), "principals", lookup_id, "secret")) { - principal.secrets.push(secret.into()); + principal.data.push(PrincipalData::Secret(secret.into())); } if let Some(description) = config.value((prefix.as_str(), "principals", lookup_id, "description")) { - principal.description = Some(description.into()); + principal + .data + .push(PrincipalData::Description(description.into())); } if let Some(quota) = config.property::((prefix.as_str(), "principals", lookup_id, "quota")) { - principal.quota = quota.into(); + principal.data.push(PrincipalData::DiskQuota(quota)); } directory.principals.push(principal); diff --git a/crates/directory/src/backend/memory/lookup.rs b/crates/directory/src/backend/memory/lookup.rs index 8c1c476d..81ffc34c 100644 --- a/crates/directory/src/backend/memory/lookup.rs +++ b/crates/directory/src/backend/memory/lookup.rs @@ -80,7 +80,7 @@ impl MemoryDirectory { if let EmailType::List(uid) = item { for principal in &self.principals { if principal.id == *uid { - if let Some(addr) = principal.emails.first() { + if let Some(addr) = principal.emails().next() { result.push(addr.clone()) } break; diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 02b6bf67..684b1bd6 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -181,16 +181,18 @@ impl BuildPrincipal for OpenIdResponse { .as_ref() .and_then(|field| self.take_field(field)); + // Build principal + let mut data = Vec::with_capacity(3); + data.push(PrincipalData::Email(email)); + if let Some(name) = full_name { + data.push(PrincipalData::Description(name)); + } + data.push(PrincipalData::Role(ROLE_USER)); Ok(Principal { id: u32::MAX, typ: Type::Individual, name: username, - description: full_name, - secrets: Default::default(), - emails: vec![email], - quota: Default::default(), - tenant: Default::default(), - data: vec![PrincipalData::Roles(vec![ROLE_USER])], + data, }) } diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs index 494b515c..bf4a5c3b 100644 --- a/crates/directory/src/backend/sql/lookup.rs +++ b/crates/directory/src/backend/sql/lookup.rs @@ -97,8 +97,14 @@ impl SqlDirectory { .await .caused_by(trc::location!())?; - if !secrets.rows.is_empty() { - principal.secrets = secrets.into(); + for row in secrets.rows { + for value in row.values { + if let Value::Text(text) = value { + principal + .data + .push(PrincipalData::Secret(text.as_ref().into())); + } + } } } @@ -127,7 +133,6 @@ impl SqlDirectory { // Obtain members if by.return_member_of && !self.mappings.query_members.is_empty() { - let mut data = Vec::new(); for row in self .sql_store .sql_query::( @@ -138,18 +143,17 @@ impl SqlDirectory { .caused_by(trc::location!())? .rows { - if let Some(Value::Text(account_id)) = row.values.first() { - data.push( - self.data_store - .get_or_create_principal_id(account_id, Type::Group) - .await - .caused_by(trc::location!())?, - ); + if let Some(Value::Text(account_name)) = row.values.first() { + let account_id = self + .data_store + .get_or_create_principal_id(account_name, Type::Group) + .await + .caused_by(trc::location!())?; + external_principal + .data + .push(PrincipalData::MemberOf(account_id)); } } - if !data.is_empty() { - external_principal.data.push(PrincipalData::MemberOf(data)); - } } // Obtain emails @@ -162,10 +166,11 @@ impl SqlDirectory { ) .await .caused_by(trc::location!())?; - external_principal.emails.extend( + external_principal.data.extend( rows.rows .into_iter() - .flat_map(|v| v.values.into_iter().map(|v| v.into_lower_string())), + .flat_map(|v| v.values.into_iter().map(|v| v.into_lower_string())) + .map(PrincipalData::Email), ); } @@ -271,7 +276,9 @@ impl SqlMappings { for (name, value) in rows.names.into_iter().zip(row.values) { if name.eq_ignore_ascii_case(&self.column_secret) { if let Value::Text(text) = value { - principal.secrets.push(text.as_ref().into()); + principal + .data + .push(PrincipalData::Secret(text.as_ref().into())); } } else if name.eq_ignore_ascii_case(&self.column_type) { match value.to_str().as_ref() { @@ -287,21 +294,26 @@ impl SqlMappings { } } else if name.eq_ignore_ascii_case(&self.column_description) { if let Value::Text(text) = value { - principal.description = Some(text.as_ref().into()); + principal + .data + .push(PrincipalData::Description(text.as_ref().into())); } } else if name.eq_ignore_ascii_case(&self.column_email) { if let Value::Text(text) = value { - principal.emails.push(text.to_lowercase()); + principal + .data + .push(PrincipalData::Email(text.to_lowercase())); } } else if name.eq_ignore_ascii_case(&self.column_quota) && let Value::Integer(quota) = value + && quota > 0 { - principal.quota = (quota as u64).into(); + principal.data.push(PrincipalData::DiskQuota(quota as u64)); } } } - principal.data.push(PrincipalData::Roles(vec![role])); + principal.data.push(PrincipalData::Role(role)); Ok(Some(principal)) } diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index 434b8ff7..28368f5d 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -5,8 +5,8 @@ */ use crate::{ - ArchivedPrincipal, FALLBACK_ADMIN_ID, Permission, PermissionGrant, Principal, PrincipalData, - ROLE_ADMIN, Type, + ArchivedPrincipal, ArchivedPrincipalData, FALLBACK_ADMIN_ID, Permission, PermissionGrant, + Principal, PrincipalData, ROLE_ADMIN, Type, backend::internal::{PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue}, }; use ahash::AHashSet; @@ -30,11 +30,6 @@ impl Principal { id, typ, name: "".into(), - description: None, - secrets: Default::default(), - emails: Default::default(), - quota: Default::default(), - tenant: Default::default(), data: Default::default(), } } @@ -51,25 +46,26 @@ impl Principal { self.name.as_str() } - pub fn quota(&self) -> u64 { - self.quota.unwrap_or_default() + pub fn quota(&self) -> Option { + self.data.iter().find_map(|d| { + if let PrincipalData::DiskQuota(quota) = d { + if *quota > 0 { Some(*quota) } else { None } + } else { + None + } + }) } - pub fn principal_quota(&self, typ: &Type) -> Option { - self.data - .iter() - .find_map(|d| { - if let PrincipalData::PrincipalQuota(q) = d { - Some(q) - } else { - None - } - }) - .and_then(|quotas| { - quotas - .iter() - .find_map(|q| if q.typ == *typ { Some(q.quota) } else { None }) - }) + pub fn directory_quota(&self, typ: &Type) -> Option { + self.data.iter().find_map(|d| { + if let PrincipalData::DirectoryQuota { quota, typ: qtyp } = d + && qtyp == typ + { + Some(*quota) + } else { + None + } + }) } // SPDX-SnippetBegin @@ -78,7 +74,13 @@ impl Principal { #[cfg(feature = "enterprise")] pub fn tenant(&self) -> Option { - self.tenant + self.data.iter().find_map(|item| { + if let PrincipalData::Tenant(tenant) = item { + Some(*tenant) + } else { + None + } + }) } // SPDX-SnippetEnd @@ -88,92 +90,104 @@ impl Principal { } pub fn description(&self) -> Option<&str> { - self.description.as_deref() - } - - pub fn member_of(&self) -> &[u32] { - self.data - .iter() - .find_map(|item| { - if let PrincipalData::MemberOf(items) = item { - items.as_slice().into() + self.data.iter().find_map(|item| { + if let PrincipalData::Description(description) = item { + if !description.is_empty() { + Some(description.as_str()) } else { None } - }) - .unwrap_or_default() - } - - pub fn member_of_mut(&mut self) -> Option<&mut Vec> { - self.data.iter_mut().find_map(|item| { - if let PrincipalData::MemberOf(items) = item { - items.into() } else { None } }) } - pub fn roles(&self) -> &[u32] { - self.data - .iter() - .find_map(|item| { - if let PrincipalData::Roles(items) = item { - items.as_slice().into() - } else { - None - } - }) - .unwrap_or_default() - } - - pub fn permissions(&self) -> &[PermissionGrant] { - self.data - .iter() - .find_map(|item| { - if let PrincipalData::Permissions(items) = item { - items.as_slice().into() - } else { - None - } - }) - .unwrap_or_default() - } - - pub fn urls(&self) -> &[String] { - self.data - .iter() - .find_map(|item| { - if let PrincipalData::Urls(items) = item { - items.as_slice().into() - } else { - None - } - }) - .unwrap_or_default() - } - - pub fn roles_mut(&mut self) -> Option<&mut Vec> { - self.data.iter_mut().find_map(|item| { - if let PrincipalData::Roles(items) = item { - items.into() + pub fn secrets(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::Secret(secret) = item { + Some(secret) } else { None } }) } - pub fn lists(&self) -> &[u32] { - self.data - .iter() - .find_map(|item| { - if let PrincipalData::Lists(items) = item { - items.as_slice().into() - } else { - None - } - }) - .unwrap_or_default() + pub fn emails(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::Email(email) = item { + Some(email) + } else { + None + } + }) + } + + pub fn into_emails(self) -> impl Iterator { + self.data.into_iter().filter_map(|item| { + if let PrincipalData::Email(email) = item { + Some(email) + } else { + None + } + }) + } + + pub fn member_of(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::MemberOf(item) = item { + Some(*item) + } else { + None + } + }) + } + + pub fn roles(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::Role(item) = item { + Some(*item) + } else { + None + } + }) + } + + pub fn permissions(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::Permission { + permission_id, + grant, + } = item + { + Permission::from_id(*permission_id).map(|permission| PermissionGrant { + permission, + grant: *grant, + }) + } else { + None + } + }) + } + + pub fn urls(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::Url(item) = item { + Some(item) + } else { + None + } + }) + } + + pub fn lists(&self) -> impl Iterator { + self.data.iter().filter_map(|item| { + if let PrincipalData::List(item) = item { + Some(item) + } else { + None + } + }) } pub fn picture(&self) -> Option<&String> { @@ -199,134 +213,163 @@ impl Principal { pub fn add_permission(&mut self, permission: Permission, grant: bool) { let permission = permission.id(); if let Some(permissions) = self.data.iter_mut().find_map(|item| { - if let PrincipalData::Permissions(permissions) = item { - Some(permissions) + if let PrincipalData::Permission { + permission_id, + grant, + } = item + { + if *permission_id == permission { + Some(grant) + } else { + None + } } else { None } }) { - if let Some(current) = permissions.iter_mut().find(|p| p.permission == permission) { - current.grant = grant; - } else { - permissions.push(PermissionGrant { permission, grant }); - } + *permissions = grant; } else { - self.data - .push(PrincipalData::Permissions(vec![PermissionGrant { - permission, - grant, - }])); + self.data.push(PrincipalData::Permission { + permission_id: permission, + grant, + }); } } pub fn add_permissions(&mut self, iter: impl Iterator) { - if let Some(permissions) = self.data.iter_mut().find_map(|item| { - if let PrincipalData::Permissions(permissions) = item { - Some(permissions) - } else { - None - } - }) { - permissions.extend(iter); - } else { - self.data.push(PrincipalData::Permissions(iter.collect())); + for grant in iter { + self.add_permission(grant.permission, grant.grant); } } pub fn remove_permission(&mut self, permission: Permission, grant: bool) { let permission = permission.id(); - if let Some(permissions) = self.data.iter_mut().find_map(|item| { - if let PrincipalData::Permissions(permissions) = item { - Some(permissions) + self.data.retain(|data| { + if let PrincipalData::Permission { + permission_id: p, + grant: g, + } = data + { + *p != permission || *g != grant } else { - None + true } - }) && let Some(idx) = permissions - .iter_mut() - .position(|p| p.permission == permission && p.grant == grant) - { - permissions.swap_remove(idx); - } + }); } pub fn remove_permissions(&mut self, grant: bool) { - if let Some(permissions) = self.data.iter_mut().find_map(|item| { - if let PrincipalData::Permissions(permissions) = item { - Some(permissions) + self.data.retain(|data| { + if let PrincipalData::Permission { grant: g, .. } = data { + *g != grant } else { - None + true } - }) { - permissions.retain(|p| p.grant != grant); - } + }); } pub fn update_external( &mut self, - mut external: Principal, + external: Principal, overwrite_emails: bool, ) -> Vec { let mut updates = Vec::new(); // Add external members - if let Some(member_of) = external.member_of_mut().filter(|s| !s.is_empty()) { - self.data - .push(PrincipalData::MemberOf(std::mem::take(member_of))); + for (idx, member_of) in external.member_of().enumerate() { + if idx == 0 { + self.data + .retain(|item| !matches!(item, PrincipalData::MemberOf(_))); + } + self.data.push(PrincipalData::MemberOf(member_of)); } // If the principal has no roles, take the ones from the external principal - if let Some(roles) = external.roles_mut().filter(|s| !s.is_empty()) - && self.roles().is_empty() - { - self.data.push(PrincipalData::Roles(std::mem::take(roles))); + for (idx, role) in external.roles().enumerate() { + if idx == 0 && self.roles().next().is_some() { + break; + } + + self.data.push(PrincipalData::Role(role)); } - if external.description.as_ref().is_some_and(|v| !v.is_empty()) - && self.description != external.description - { - self.description = external.description; - updates.push(PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String(self.description.clone().unwrap()), - )); + // Update description + match (external.description(), self.description()) { + (Some(external), current) if Some(external) != current => { + if current.is_some() { + self.data + .retain(|item| !matches!(item, PrincipalData::Description(_))); + } + self.data + .push(PrincipalData::Description(external.to_string())); + updates.push(PrincipalUpdate::set( + PrincipalField::Description, + PrincipalValue::String(external.to_string()), + )); + } + _ => {} } - if !external.secrets.is_empty() && external.secrets != self.secrets { - self.secrets = external.secrets; + // Update secrets + if update_list(external.secrets(), self.secrets()) { + let mut new_secrets = Vec::new(); + self.data + .retain(|item| !matches!(item, PrincipalData::Secret(_))); + self.data.extend(external.secrets().map(|secret| { + new_secrets.push(secret.to_string()); + PrincipalData::Secret(secret.to_string()) + })); updates.push(PrincipalUpdate::set( PrincipalField::Secrets, - PrincipalValue::StringList(self.secrets.clone()), + PrincipalValue::StringList(new_secrets), )); } - if !external.emails.is_empty() && external.emails != self.emails { + // Update emails + if update_list(external.emails(), self.emails()) { if overwrite_emails { - self.emails = external.emails; + let mut new_emails = Vec::new(); + self.data + .retain(|item| !matches!(item, PrincipalData::Email(_))); + self.data.extend(external.emails().map(|email| { + new_emails.push(email.to_string()); + PrincipalData::Email(email.to_string()) + })); updates.push(PrincipalUpdate::set( PrincipalField::Emails, - PrincipalValue::StringList(self.emails.clone()), + PrincipalValue::StringList(new_emails), )); } else { // Missing emails are appended to avoid overwriting locally defined aliases // This means that old email addresses need to be deleted either manually or using the API - for email in external.emails { + let current_emails = self.emails().collect::>(); + let mut new_emails = Vec::new(); + for email in external.emails() { let email = email.to_lowercase(); - if !self.emails.contains(&email) { + if !current_emails.contains(&email) { updates.push(PrincipalUpdate::add_item( PrincipalField::Emails, PrincipalValue::String(email.clone()), )); - self.emails.push(email); + new_emails.push(PrincipalData::Email(email)); } } + self.data.extend(new_emails); } } - if external.quota.is_some() && self.quota != external.quota { - self.quota = external.quota; + let external_quota = external.quota(); + let this_quota = self.quota(); + if let Some(external_quota) = external_quota + && this_quota.is_none_or(|this_quota| this_quota != external_quota) + { + if this_quota.is_some() { + self.data + .retain(|item| !matches!(item, PrincipalData::DiskQuota(_))); + } + self.data.push(PrincipalData::DiskQuota(external_quota)); updates.push(PrincipalUpdate::set( PrincipalField::Quota, - PrincipalValue::Integer(self.quota.unwrap()), + PrincipalValue::Integer(external_quota), )); } @@ -335,23 +378,10 @@ impl Principal { pub fn object_size(&self) -> usize { self.name.len() - + self.description.as_ref().map_or(0, |d| d.len()) - + self.secrets.iter().map(|s| s.len()).sum::() - + self.emails.iter().map(|e| e.len()).sum::() + self .data .iter() - .map(|d| match d { - PrincipalData::MemberOf(items) - | PrincipalData::Roles(items) - | PrincipalData::Lists(items) => items.len() * U32_LEN, - PrincipalData::Permissions(items) => items.len() * U32_LEN, - PrincipalData::ExternalMembers(items) | PrincipalData::Urls(items) => { - items.iter().map(|s| s.len()).sum::() - } - PrincipalData::PrincipalQuota(items) => items.len() * U32_LEN, - PrincipalData::Picture(value) | PrincipalData::Locale(value) => value.len(), - }) + .map(|item| item.object_size()) .sum::() } @@ -360,12 +390,60 @@ impl Principal { id: FALLBACK_ADMIN_ID, typ: Type::Individual, name: "Fallback Administrator".into(), - secrets: vec![fallback_pass.into()], - data: vec![PrincipalData::Roles(vec![ROLE_ADMIN])], - description: Default::default(), - emails: Default::default(), - quota: Default::default(), - tenant: Default::default(), + data: vec![ + PrincipalData::Role(ROLE_ADMIN), + PrincipalData::Secret(fallback_pass.into()), + ], + } + } +} + +fn update_list<'x>( + new: impl Iterator, + mut current: impl Iterator, +) -> bool { + let mut new = new.peekable(); + if new.peek().is_some() { + loop { + match (new.next(), current.next()) { + (Some(n), Some(c)) => { + if n != c { + return true; + } + } + (Some(_), None) => { + return true; + } + (None, Some(_)) => { + return true; + } + (None, None) => { + return false; + } + } + } + } else { + false + } +} + +impl PrincipalData { + pub fn object_size(&self) -> usize { + match self { + PrincipalData::Secret(v) + | PrincipalData::Description(v) + | PrincipalData::Email(v) + | PrincipalData::Picture(v) + | PrincipalData::ExternalMember(v) + | PrincipalData::Url(v) + | PrincipalData::Locale(v) => v.len(), + PrincipalData::DiskQuota(_) => U64_LEN, + PrincipalData::Permission { .. } => U32_LEN + 1, + PrincipalData::DirectoryQuota { .. } | PrincipalData::ObjectQuota { .. } => U64_LEN + 1, + PrincipalData::Tenant(_) + | PrincipalData::MemberOf(_) + | PrincipalData::Role(_) + | PrincipalData::List(_) => U32_LEN, } } } @@ -874,9 +952,13 @@ pub(crate) fn build_search_index( let mut new_words = AHashSet::new(); if let Some(current) = current { - for word in [Some(current.name.as_str()), current.description.as_deref()] + for word in [Some(current.name.as_str())] .into_iter() - .chain(current.emails.iter().map(|s| Some(s.as_str()))) + .chain(current.data.iter().map(|s| match s { + ArchivedPrincipalData::Description(v) => Some(v.as_str()), + ArchivedPrincipalData::Email(v) => Some(v.as_str()), + _ => None, + })) .flatten() { current_words.extend(WordTokenizer::new(word, MAX_TOKEN_LENGTH).map(|t| t.word)); @@ -884,9 +966,13 @@ pub(crate) fn build_search_index( } if let Some(new) = new { - for word in [Some(new.name.as_str()), new.description.as_deref()] + for word in [Some(new.name.as_str())] .into_iter() - .chain(new.emails.iter().map(|s| Some(s.as_str()))) + .chain(new.data.iter().map(|s| match s { + PrincipalData::Description(v) => Some(v.as_str()), + PrincipalData::Email(v) => Some(v.as_str()), + _ => None, + })) .flatten() { new_words.extend(WordTokenizer::new(word, MAX_TOKEN_LENGTH).map(|t| t.word)); diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index a0cf8bd5..3c9e7253 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -31,7 +31,7 @@ impl Principal { let mut is_authenticated = false; let mut is_app_authenticated = false; - for secret in self.secrets.iter() { + for secret in self.secrets() { if secret.is_otp_auth() { if !is_totp_verified && !is_totp_token_missing { is_totp_required = true; @@ -96,7 +96,7 @@ impl Principal { } else { if is_totp_verified { // TOTP URL appeared after password hash in secrets list - for secret in &self.secrets { + for secret in self.secrets() { if secret.is_password() && verify_secret_hash(secret, code).await? { return Ok(true); } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index b8807f88..c51e08e5 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -6,9 +6,6 @@ #![warn(clippy::large_futures)] -use core::cache::CachedDirectory; -use std::{fmt::Debug, sync::Arc}; - use ahash::AHashMap; use backend::{ imap::{ImapDirectory, ImapError}, @@ -17,13 +14,15 @@ use backend::{ smtp::SmtpDirectory, sql::SqlDirectory, }; - +use core::cache::CachedDirectory; use deadpool::managed::PoolError; use ldap3::LdapError; use mail_send::Credentials; use proc_macros::EnumMethods; +use std::{fmt::Debug, sync::Arc}; use store::Store; use trc::ipc::bitset::Bitset; +use types::collection::Collection; pub mod backend; pub mod core; @@ -40,31 +39,38 @@ pub struct Principal { pub id: u32, pub typ: Type, pub name: String, - pub description: Option, - pub secrets: Vec, - pub emails: Vec, - pub quota: Option, - pub tenant: Option, pub data: Vec, } #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] pub enum PrincipalData { - MemberOf(Vec), - Roles(Vec), - Lists(Vec), - Permissions(Vec), + Secret(String), + + // Permissions and memberships + Tenant(u32), + MemberOf(u32), + Role(u32), + List(u32), + Permission { permission_id: u32, grant: bool }, + + // Quotas + DiskQuota(u64), + DirectoryQuota { quota: u32, typ: Type }, + ObjectQuota { quota: u32, typ: Collection }, + + // Profile data + Description(String), + Email(String), Picture(String), - ExternalMembers(Vec), - Urls(Vec), - PrincipalQuota(Vec), + ExternalMember(String), + Url(String), Locale(String), } -#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] -pub struct PrincipalQuota { - pub quota: u64, - pub typ: Type, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionGrant { + pub permission: Permission, + pub grant: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -73,12 +79,6 @@ pub struct MemberOf { pub typ: Type, } -#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] -pub struct PermissionGrant { - pub permission: u32, - pub grant: bool, -} - #[derive( rkyv::Archive, rkyv::Deserialize, diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index c6d75b50..92ddeb2c 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -124,7 +124,7 @@ impl SieveScriptIngest for Server { .caused_by(trc::location!())? .and_then(|p| { instance.set_user_full_name(p.description().unwrap_or_else(|| p.name())); - p.emails.into_iter().next() + p.into_emails().next() }); // Set account address diff --git a/crates/http/src/auth/oauth/registration.rs b/crates/http/src/auth/oauth/registration.rs index 82f5e3f2..7b7605e7 100644 --- a/crates/http/src/auth/oauth/registration.rs +++ b/crates/http/src/auth/oauth/registration.rs @@ -119,7 +119,7 @@ impl ClientRegistrationHandler for Server { .filter(|p| p.typ() == Type::OauthClient) { if let Some(redirect_uri) = redirect_uri { - if client.urls().iter().any(|uri| uri == redirect_uri) { + if client.urls().any(|uri| uri == redirect_uri) { return Ok(None); } } else { diff --git a/crates/http/src/autoconfig/mod.rs b/crates/http/src/autoconfig/mod.rs index 255e2ea6..ec0b847d 100644 --- a/crates/http/src/autoconfig/mod.rs +++ b/crates/http/src/autoconfig/mod.rs @@ -210,8 +210,8 @@ impl Autoconfig for Server { .query(QueryParams::id(id).with_return_member_of(false)) .await && principal - .emails - .first() + .emails() + .next() .is_some_and(|email| email.eq_ignore_ascii_case(emailaddress)) { account_name = principal.name; diff --git a/crates/http/src/management/principal.rs b/crates/http/src/management/principal.rs index e9b17330..42f0c77a 100644 --- a/crates/http/src/management/principal.rs +++ b/crates/http/src/management/principal.rs @@ -710,7 +710,7 @@ impl PrincipalManager for Server { .await? .ok_or_else(|| trc::ManageEvent::NotFound.into_err())?; - for secret in &principal.secrets { + for secret in principal.secrets() { if secret.is_otp_auth() { response.otp_auth = true; } else if let Some((app_name, _)) = diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 5243e21c..85849078 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -429,7 +429,7 @@ impl RequestHandler for Server { set_account_id_if_missing(&mut req.account_id, access_token); access_token.assert_is_member(req.account_id)?; - self.identity_set(req).await?.into() + self.identity_set(req, access_token).await?.into() } SetRequestMethod::EmailSubmission(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); @@ -511,7 +511,9 @@ impl RequestHandler for Server { set_account_id_if_missing(&mut req.account_id, access_token); access_token.assert_is_member(req.account_id)?; - self.participant_identity_set(req).await?.into() + self.participant_identity_set(req, access_token) + .await? + .into() } }, RequestMethod::Changes(mut req) => { diff --git a/crates/jmap/src/identity/get.rs b/crates/jmap/src/identity/get.rs index 8c953aa5..d0fbfd6f 100644 --- a/crates/jmap/src/identity/get.rs +++ b/crates/jmap/src/identity/get.rs @@ -6,7 +6,7 @@ use crate::changes::state::StateManager; use common::{Server, storage::index::ObjectIndexBuilder}; -use directory::QueryParams; +use directory::{PrincipalData, QueryParams}; use email::identity::{ArchivedEmailAddress, Identity}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, @@ -162,7 +162,18 @@ impl IdentityGet for Server { } else { return Ok(identity_ids); }; - let num_emails = principal.emails.len(); + + let mut emails = Vec::new(); + let mut description = None; + for data in principal.data { + match data { + PrincipalData::Email(v) => emails.push(v), + PrincipalData::Description(v) => description = Some(v), + _ => {} + } + } + + let num_emails = emails.len(); if num_emails == 0 { return Ok(identity_ids); } @@ -173,13 +184,13 @@ impl IdentityGet for Server { .with_collection(Collection::Identity); // Create identities - let name = principal.description.unwrap_or(principal.name); + let name = description.unwrap_or(principal.name); let mut next_document_id = self .store() .assign_document_ids(account_id, Collection::Identity, num_emails as u64) .await .caused_by(trc::location!())?; - for email in &principal.emails { + for email in &emails { let email = sanitize_email(email).unwrap_or_default(); if email.is_empty() || email.starts_with('@') { continue; diff --git a/crates/jmap/src/identity/set.rs b/crates/jmap/src/identity/set.rs index 0732ff2c..adf0851d 100644 --- a/crates/jmap/src/identity/set.rs +++ b/crates/jmap/src/identity/set.rs @@ -4,11 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, storage::index::ObjectIndexBuilder}; +use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; use directory::QueryParams; use email::identity::{EmailAddress, Identity}; use jmap_proto::{ - error::set::SetError, + error::set::{SetError, SetErrorType}, method::set::{SetRequest, SetResponse}, object::identity::{self, IdentityProperty, IdentityValue}, references::resolve::ResolveCreatedReference, @@ -29,6 +29,7 @@ pub trait IdentitySet: Sync + Send { fn identity_set( &self, request: SetRequest<'_, identity::Identity>, + access_token: &AccessToken, ) -> impl Future>> + Send; } @@ -36,6 +37,7 @@ impl IdentitySet for Server { async fn identity_set( &self, mut request: SetRequest<'_, identity::Identity>, + access_token: &AccessToken, ) -> trc::Result> { let account_id = request.account_id.document_id(); let identity_ids = self @@ -66,7 +68,7 @@ impl IdentitySet for Server { .directory() .query(QueryParams::id(account_id).with_return_member_of(false)) .await? - .is_none_or(|p| !p.emails.iter().any(|e| e == &identity.email)) + .is_none_or(|p| !p.emails().any(|e| e == &identity.email)) { response.not_created.append( id, @@ -88,6 +90,18 @@ impl IdentitySet for Server { continue 'create; } + // Validate quota + if identity_ids.len() >= access_token.object_quota(Collection::Identity) as u64 { + response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many identities, ", + "please delete some before adding a new one." + )), + ); + continue 'create; + } + // Insert record let document_id = self .store() diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 78f2d3b4..db275c0a 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -98,6 +98,18 @@ impl MailboxSet for Server { continue; }; + // Validate quota + if ctx.mailbox_ids.len() >= access_token.object_quota(Collection::Mailbox) as u64 { + ctx.response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many mailboxes, ", + "please delete some before adding a new one." + )), + ); + continue 'create; + } + match self.mailbox_set_item(object, None, &ctx).await? { Ok(builder) => { batch diff --git a/crates/jmap/src/participant_identity/get.rs b/crates/jmap/src/participant_identity/get.rs index 04572198..8afa494c 100644 --- a/crates/jmap/src/participant_identity/get.rs +++ b/crates/jmap/src/participant_identity/get.rs @@ -5,7 +5,7 @@ */ use common::Server; -use directory::QueryParams; +use directory::{PrincipalData, QueryParams}; use groupware::calendar::{ParticipantIdentities, ParticipantIdentity}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, @@ -140,15 +140,23 @@ impl ParticipantIdentityGet for Server { } else { return Ok(None); }; - let num_emails = principal.emails.len(); + let mut emails = Vec::new(); + let mut description = None; + for data in principal.data { + match data { + PrincipalData::Email(v) => emails.push(v), + PrincipalData::Description(v) => description = Some(v), + _ => {} + } + } + let num_emails = emails.len(); if num_emails == 0 { return Ok(None); } // Build identities let identities = ParticipantIdentities { - identities: principal - .emails + identities: emails .iter() .enumerate() .map(|(id, email)| ParticipantIdentity { @@ -158,7 +166,7 @@ impl ParticipantIdentityGet for Server { }) .collect(), default: 0, - default_name: principal.description.unwrap_or(principal.name), + default_name: description.unwrap_or(principal.name), }; let mut batch = BatchBuilder::new(); diff --git a/crates/jmap/src/participant_identity/set.rs b/crates/jmap/src/participant_identity/set.rs index 8a705e5a..d8334097 100644 --- a/crates/jmap/src/participant_identity/set.rs +++ b/crates/jmap/src/participant_identity/set.rs @@ -5,11 +5,11 @@ */ use crate::participant_identity::get::ParticipantIdentityGet; -use common::Server; +use common::{Server, auth::AccessToken}; use directory::QueryParams; use groupware::calendar::{ParticipantIdentities, ParticipantIdentity}; use jmap_proto::{ - error::set::SetError, + error::set::{SetError, SetErrorType}, method::set::{SetRequest, SetResponse}, object::participant_identity::{self, ParticipantIdentityProperty, ParticipantIdentityValue}, request::{IntoValid, reference::MaybeIdReference}, @@ -17,6 +17,7 @@ use jmap_proto::{ use jmap_tools::{Key, Value}; use store::{ Serialize, + ahash::AHashSet, write::{Archiver, BatchBuilder}, }; use trc::AddContext; @@ -27,6 +28,7 @@ pub trait ParticipantIdentitySet: Sync + Send { fn participant_identity_set( &self, request: SetRequest<'_, participant_identity::ParticipantIdentity>, + access_token: &AccessToken, ) -> impl Future>> + Send; } @@ -34,6 +36,7 @@ impl ParticipantIdentitySet for Server { async fn participant_identity_set( &self, mut request: SetRequest<'_, participant_identity::ParticipantIdentity>, + access_token: &AccessToken, ) -> trc::Result> { let account_id = request.account_id.document_id(); let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?; @@ -55,7 +58,7 @@ impl ParticipantIdentitySet for Server { .directory() .query(QueryParams::id(account_id).with_return_member_of(false)) .await? - .map(|p| p.emails) + .map(|p| p.into_emails().collect::>()) .unwrap_or_default(); // Process creates @@ -82,6 +85,20 @@ impl ParticipantIdentitySet for Server { continue 'create; } + // Validate quota + if identities.identities.len() + >= access_token.object_quota(Collection::Identity) as usize + { + response.not_created.append( + id, + SetError::new(SetErrorType::OverQuota).with_description(concat!( + "There are too many identities, ", + "please delete some before adding a new one." + )), + ); + continue 'create; + } + let document_id = identities .identities .iter() @@ -180,7 +197,7 @@ impl ParticipantIdentitySet for Server { fn validate_identity_value( update: Value<'_, ParticipantIdentityProperty, ParticipantIdentityValue>, identity: &mut ParticipantIdentity, - allowed_emails: &[String], + allowed_emails: &AHashSet, ) -> Result<(), SetError> { let mut changed_address = false; for (property, value) in update.into_expanded_object() { diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs index 50bb09b1..8c51b9d9 100644 --- a/crates/jmap/src/principal/get.rs +++ b/crates/jmap/src/principal/get.rs @@ -123,8 +123,8 @@ impl PrincipalGet for Server { .map(|v| Value::Str(v.to_string().into())) .unwrap_or(Value::Null), PrincipalProperty::Email => principal - .emails - .first() + .emails() + .next() .map(|email| Value::Str(email.to_string().into())) .unwrap_or(Value::Null), PrincipalProperty::Accounts => Value::Object(Map::from(vec![( @@ -175,8 +175,8 @@ impl PrincipalGet for Server { Key::Borrowed("calendarAddress"), Value::Str( principal - .emails - .first() + .emails() + .next() .map(|email| format!("mailto:{}", email)) .unwrap_or_default() .into(), diff --git a/crates/jmap/src/push/set.rs b/crates/jmap/src/push/set.rs index e6e7bdb1..c86066a4 100644 --- a/crates/jmap/src/push/set.rs +++ b/crates/jmap/src/push/set.rs @@ -8,7 +8,7 @@ use base64::{Engine, engine::general_purpose}; use common::{Server, auth::AccessToken, ipc::PushEvent}; use email::push::{Keys, PushSubscription, PushSubscriptions}; use jmap_proto::{ - error::set::SetError, + error::set::{SetError, SetErrorType}, method::set::{SetRequest, SetResponse}, object::push_subscription::{self, PushSubscriptionProperty, PushSubscriptionValue}, references::resolve::ResolveCreatedReference, @@ -80,8 +80,10 @@ impl PushSubscriptionSet for Server { 'create: for (id, object) in request.unwrap_create() { let mut push = PushSubscription::default(); - if subscriptions.subscriptions.len() >= self.core.jmap.push_max_total { - response.not_created.append(id, SetError::forbidden().with_description( + if subscriptions.subscriptions.len() + >= access_token.object_quota(Collection::PushSubscription) as usize + { + response.not_created.append(id, SetError::new(SetErrorType::OverQuota).with_description( "There are too many subscriptions, please delete some before adding a new one.", )); continue 'create; diff --git a/crates/jmap/src/sieve/set.rs b/crates/jmap/src/sieve/set.rs index d6b69e37..c681cf4d 100644 --- a/crates/jmap/src/sieve/set.rs +++ b/crates/jmap/src/sieve/set.rs @@ -113,7 +113,7 @@ impl SieveScriptSet for Server { // Process creates let mut batch = BatchBuilder::new(); for (id, object) in request.unwrap_create() { - if sieve_ids.len() as usize <= self.core.jmap.sieve_max_scripts { + if sieve_ids.len() < access_token.object_quota(Collection::SieveScript) as u64 { match self .sieve_set_item(object, None, &ctx, session.session_id) .await? diff --git a/crates/managesieve/src/op/putscript.rs b/crates/managesieve/src/op/putscript.rs index 415fad27..c87be914 100644 --- a/crates/managesieve/src/op/putscript.rs +++ b/crates/managesieve/src/op/putscript.rs @@ -61,7 +61,7 @@ impl Session { .caused_by(trc::location!())? .map(|ids| ids.len() as usize) .unwrap_or(0) - > self.server.core.jmap.sieve_max_scripts + > access_token.object_quota(Collection::SieveScript) as usize { return Err(trc::ManageSieveEvent::Error .into_err() diff --git a/crates/migration/src/principal.rs b/crates/migration/src/principal.rs index 71be6c75..5145e73c 100644 --- a/crates/migration/src/principal.rs +++ b/crates/migration/src/principal.rs @@ -6,8 +6,7 @@ use common::Server; use directory::{ - Permission, PermissionGrant, Principal, PrincipalData, PrincipalQuota, ROLE_ADMIN, ROLE_USER, - Type, + Permission, Principal, PrincipalData, ROLE_ADMIN, ROLE_USER, Type, backend::internal::{PrincipalField, PrincipalSet}, }; use nlp::tokenizers::word::WordTokenizer; @@ -179,52 +178,52 @@ impl FromLegacy for Principal { let mut principal = Principal { id: legacy.id, typ: legacy.typ, - tenant: legacy.tenant(), name: legacy.name().to_string(), - description: legacy.take_str(PrincipalField::Description), - secrets: Default::default(), - emails: Default::default(), - quota: Default::default(), data: Default::default(), }; // Map fields - principal.secrets = legacy + for secret in legacy .take_str_array(PrincipalField::Secrets) - .unwrap_or_default(); - principal.emails = legacy + .unwrap_or_default() + { + principal.data.push(PrincipalData::Secret(secret)); + } + for email in legacy .take_str_array(PrincipalField::Emails) - .unwrap_or_default(); + .unwrap_or_default() + { + principal.data.push(PrincipalData::Email(email)); + } if let Some(picture) = legacy.take_str(PrincipalField::Picture) { principal.data.push(PrincipalData::Picture(picture)); } - if let Some(urls) = legacy.take_str_array(PrincipalField::Urls) { - principal.data.push(PrincipalData::Urls(urls)); + for url in legacy + .take_str_array(PrincipalField::Urls) + .unwrap_or_default() + { + principal.data.push(PrincipalData::Url(url)); } - if let Some(urls) = legacy.take_str_array(PrincipalField::ExternalMembers) { - principal.data.push(PrincipalData::ExternalMembers(urls)); + for member in legacy + .take_str_array(PrincipalField::ExternalMembers) + .unwrap_or_default() + { + principal.data.push(PrincipalData::ExternalMember(member)); } - if let Some(quotas) = legacy.take_int_array(PrincipalField::Quota) { - let mut principal_quotas = Vec::new(); + if let Some(quotas) = legacy.take_int_array(PrincipalField::Quota) { for (idx, quota) in quotas.into_iter().take(Type::MAX_ID + 2).enumerate() { if quota != 0 { if idx != 0 { - principal_quotas.push(PrincipalQuota { - quota, + principal.data.push(PrincipalData::DirectoryQuota { + quota: quota as u32, typ: Type::from_u8((idx - 1) as u8), }); } else { - principal.quota = Some(quota); + principal.data.push(PrincipalData::DiskQuota(quota)); } } } - - if !principal_quotas.is_empty() { - principal - .data - .push(PrincipalData::PrincipalQuota(principal_quotas)); - } } // Map permissions @@ -243,15 +242,12 @@ impl FromLegacy for Principal { } } if !permissions.is_empty() { - principal.data.push(PrincipalData::Permissions( - permissions - .into_iter() - .map(|(k, v)| PermissionGrant { - permission: k, - grant: !v, - }) - .collect(), - )); + for (k, v) in permissions { + principal.data.push(PrincipalData::Permission { + permission_id: k, + grant: !v, + }); + } } principal @@ -363,9 +359,9 @@ fn deserialize_string(bytes: &mut Iter<'_, u8>) -> Option { pub(crate) fn build_search_index(batch: &mut BatchBuilder, principal_id: u32, new: &Principal) { let mut new_words = AHashSet::new(); - for word in [Some(new.name.as_str()), new.description.as_deref()] + for word in [Some(new.name.as_str()), new.description()] .into_iter() - .chain(new.emails.iter().map(|s| Some(s.as_str()))) + .chain(new.emails().map(|s| Some(s.as_str()))) .flatten() { new_words.extend(WordTokenizer::new(word, MAX_TOKEN_LENGTH).map(|t| t.word)); diff --git a/crates/types/src/collection.rs b/crates/types/src/collection.rs index 10e4bb72..d40416f5 100644 --- a/crates/types/src/collection.rs +++ b/crates/types/src/collection.rs @@ -12,7 +12,18 @@ use std::{ }; use utils::map::bitmap::BitmapItem; -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] +#[derive( + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, + Debug, + Clone, + Copy, + Hash, + PartialEq, + Eq, + Default, +)] #[repr(u8)] pub enum Collection { Email = 0, @@ -60,6 +71,8 @@ pub enum VanishedCollection { } impl Collection { + pub const MAX: usize = Collection::None as usize; + pub fn main_collection(&self) -> Collection { match self { Collection::Email => Collection::Mailbox, @@ -343,6 +356,26 @@ impl Collection { Collection::None => "", } } + + pub fn as_config_case(&self) -> &'static str { + match self { + Collection::PushSubscription => "push-subscription", + Collection::Email => "email", + Collection::Mailbox => "mailbox", + Collection::Thread => "thread", + Collection::Identity => "identity", + Collection::EmailSubmission => "email-submission", + Collection::SieveScript => "sieve-script", + Collection::Principal => "principal", + Collection::Calendar => "calendar", + Collection::CalendarEvent => "calendar-event", + Collection::AddressBook => "address-book", + Collection::ContactCard => "contact-card", + Collection::FileNode => "file-node", + Collection::CalendarEventNotification => "calendar-event-notification", + Collection::None => "", + } + } } impl FromStr for Collection { diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs index e1236403..ee636e38 100644 --- a/tests/src/directory/mod.rs +++ b/tests/src/directory/mod.rs @@ -590,14 +590,14 @@ impl From for TestPrincipal { Self { id: value.id(), typ: value.typ(), - quota: value.quota(), - member_of: value.member_of().iter().map(|v| v.to_string()).collect(), - roles: value.roles().iter().map(|v| v.to_string()).collect(), - lists: value.lists().iter().map(|v| v.to_string()).collect(), + quota: value.quota().unwrap_or_default(), + member_of: value.member_of().map(|v| v.to_string()).collect(), + roles: value.roles().map(|v| v.to_string()).collect(), + lists: value.lists().map(|v| v.to_string()).collect(), + secrets: value.secrets().map(|v| v.to_string()).collect(), + emails: value.emails().map(|v| v.to_string()).collect(), + description: value.description().map(|v| v.to_string()), name: value.name, - secrets: value.secrets, - emails: value.emails, - description: value.description, } } } diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index 7221d89d..39bc35dc 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -132,7 +132,7 @@ async fn oidc_directory() { .unwrap(); assert_eq!(principal.name(), "jdoe"); assert_eq!( - principal.emails.first().map(|s| s.as_str()), + principal.emails().next().map(|s| s.as_str()), Some("john@example.org") ); assert_eq!(principal.description(), Some("John Doe"));