This commit is contained in:
mdecimus 2025-10-22 18:02:46 +02:00
parent 49b384d740
commit 86c963ef68
27 changed files with 528 additions and 395 deletions

View file

@ -31,10 +31,10 @@ body:
label: Version
description: What version of our software are you running?
options:
- v0.14.x
- v0.13.x
- v0.12.x
- v0.11.x
- v0.10.x or lower
- v0.11.x or lower
validations:
required: true
- type: dropdown

View file

@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.14.0] - 2025-10-22
If you are upgrading from v0.13.4 and below, this version includes **breaking changes** to the internal directory, calendar and contacts. Please read the [upgrading documentation](https://stalw.art/docs/install/upgrade) for more information on how to upgrade from previous versions.
## Added
- JMAP for Calendars ([draft-ietf-jmap-calendars](https://datatracker.ietf.org/doc/draft-ietf-jmap-calendars/)).
- JMAP for Contacts ([RFC 9610](https://datatracker.ietf.org/doc/rfc9610/)).
- JMAP for File Storage ([draft-ietf-jmap-filenode](https://datatracker.ietf.org/doc/draft-ietf-jmap-filenode/)).
- JMAP Sharing ([RFC 9670](https://datatracker.ietf.org/doc/rfc9670/))
- CalDAV: support for `supported-calendar-component-set` (#1893)
- i18n: Greek language support (contributed by @infl00p)
- i18n: Swedish language support (contributed by @purung)
## Changed
## Fixed
- Push Subscription: Clean-up of expired subscriptions and cluster notification of changes (#1248)
- CalDAV: Per-user CalDAV properties (#2058)
## [0.13.4] - 2025-09-30
If you are upgrading from v0.11.x or v0.12.x, this version includes **breaking changes** to the message queue and MTA configuration. Please read the [UPGRADING.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md) file for more information on how to upgrade from previous versions.

View file

@ -6,7 +6,8 @@ We provide security updates for the following versions of Stalwart:
| Version | Supported | End of Support |
| ------- | ------------------ | -------------- |
| 0.13.x | :white_check_mark: | TBD |
| 0.14.x | :white_check_mark: | TBD |
| 0.13.x | :white_check_mark: | 2026-02-28 |
| 0.12.x | :white_check_mark: | 2025-12-31 |
| 0.11.x | :white_check_mark: | 2025-12-31 |
| < 0.11 | :x: | Ended |

View file

@ -231,7 +231,7 @@ impl Server {
.data
.into_iter()
.filter_map(|v| {
if let PrincipalData::Secret(secret) = v {
if let PrincipalData::Password(secret) = v {
Some(secret)
} else {
None

View file

@ -344,10 +344,10 @@ impl JmapConfig {
}),
contact_parse_max_items: config
.property("jmap.contact.parse.max-items")
.unwrap_or(100),
.unwrap_or(10),
calendar_parse_max_items: config
.property("jmap.calendar.parse.max-items")
.unwrap_or(100),
.unwrap_or(10),
default_folders,
shared_folder,
};

View file

@ -4,12 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::*;
use crate::expr::{if_block::IfBlock, tokenizer::TokenMap};
use ahash::AHashSet;
use std::time::Duration;
use std::{hash::Hasher, time::Duration};
use utils::config::{Config, Rate, utils::ParseValue};
use super::*;
use xxhash_rust::xxh3::Xxh3Builder;
#[derive(Clone)]
pub struct Network {
@ -377,8 +377,9 @@ impl ClusterRole {
shard_id,
total_shards,
} => {
(ahash::RandomState::new().hash_one(item) % (*total_shards as u64))
== (*shard_id as u64)
let mut hasher = Xxh3Builder::new().with_seed(191179).build();
item.hash(&mut hasher);
hasher.finish() % (*total_shards as u64) == *shard_id as u64
}
}
}

View file

@ -167,8 +167,8 @@ fn extract_filter_range(query: &CalendarQuery) -> Option<TimeRange> {
fn extract_data_range(propfind: &PropFind, filter_range: Option<TimeRange>) -> Option<TimeRange> {
let props = match propfind {
PropFind::PropName => todo!(),
PropFind::AllProp(props) | PropFind::Prop(props) => props,
PropFind::PropName => &[][..],
};
for prop in props {

View file

@ -50,7 +50,9 @@ impl DirectoryStore for Store {
&& let Some(mut principal) = self.get_principal(account_id).await?
{
if let Some(secret) = secret
&& !principal.verify_secret(secret, by.only_app_pass).await?
&& !principal
.verify_secret(secret, by.only_app_pass, true)
.await?
{
return Ok(None);
}

View file

@ -321,7 +321,7 @@ impl ManageDirectory for Store {
return Err(err_exists(PrincipalField::Name, name));
}
let mut principal_create = Principal::new(0, principal_set.typ());
let mut create_principal = Principal::new(0, principal_set.typ());
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
@ -352,9 +352,9 @@ impl ManageDirectory for Store {
));
}
principal_create.data.push(PrincipalData::Tenant(tenant_id));
create_principal.data.push(PrincipalData::Tenant(tenant_id));
if !matches!(principal_create.typ, Type::Tenant | Type::Domain) {
if !matches!(create_principal.typ, Type::Tenant | Type::Domain) {
if let Some(domain) = name.try_domain_part()
&& self
.get_principal_info(domain)
@ -377,37 +377,47 @@ impl ManageDirectory for Store {
// SPDX-SnippetEnd
// Set fields
principal_create.name = name;
create_principal.name = name;
let mut has_secret = false;
for secret in principal_set
.take_str_array(PrincipalField::Secrets)
.unwrap_or_default()
{
principal_create.data.push(PrincipalData::Secret(secret));
if secret.is_otp_secret() {
create_principal.data.push(PrincipalData::OtpAuth(secret));
} else if secret.is_app_secret() {
create_principal
.data
.push(PrincipalData::AppPassword(secret));
} else if !has_secret {
has_secret = true;
create_principal.data.push(PrincipalData::Password(secret));
}
}
if let Some(description) = principal_set.take_str(PrincipalField::Description) {
principal_create
create_principal
.data
.push(PrincipalData::Description(description));
}
if let Some(picture) = principal_set.take_str(PrincipalField::Picture) {
principal_create.data.push(PrincipalData::Picture(picture));
create_principal.data.push(PrincipalData::Picture(picture));
}
if let Some(picture) = principal_set.take_str(PrincipalField::Locale) {
principal_create.data.push(PrincipalData::Locale(picture));
create_principal.data.push(PrincipalData::Locale(picture));
}
for url in principal_set
.take_str_array(PrincipalField::Urls)
.unwrap_or_default()
{
principal_create.data.push(PrincipalData::Url(url));
create_principal.data.push(PrincipalData::Url(url));
}
for member in principal_set
.take_str_array(PrincipalField::ExternalMembers)
.unwrap_or_default()
{
principal_create
create_principal
.data
.push(PrincipalData::ExternalMember(member));
}
@ -415,12 +425,12 @@ impl ManageDirectory for Store {
for (idx, quota) in quotas.into_iter().take(Type::MAX_ID + 2).enumerate() {
if quota != 0 {
if idx != 0 {
principal_create.data.push(PrincipalData::DirectoryQuota {
create_principal.data.push(PrincipalData::DirectoryQuota {
quota: quota as u32,
typ: Type::from_u8((idx - 1) as u8),
});
} else {
principal_create.data.push(PrincipalData::DiskQuota(quota));
create_principal.data.push(PrincipalData::DiskQuota(quota));
}
}
}
@ -511,7 +521,7 @@ impl ManageDirectory for Store {
}
if !permissions.is_empty() {
for (permission, v) in permissions {
principal_create.data.push(PrincipalData::Permission {
create_principal.data.push(PrincipalData::Permission {
permission_id: permission.id(),
grant: !v,
});
@ -519,7 +529,7 @@ impl ManageDirectory for Store {
}
// Make sure the e-mail is not taken and validate domain
if principal_create.typ != Type::OauthClient {
if create_principal.typ != Type::OauthClient {
for (idx, email) in principal_set
.take_str_array(PrincipalField::Emails)
.unwrap_or_default()
@ -540,11 +550,11 @@ impl ManageDirectory for Store {
.ok_or_else(|| not_found(domain.to_string()))?;
}
if idx == 0 {
principal_create
create_principal
.data
.push(PrincipalData::PrimaryEmail(email));
} else {
principal_create.data.push(PrincipalData::EmailAlias(email));
create_principal.data.push(PrincipalData::EmailAlias(email));
}
}
}
@ -560,13 +570,13 @@ impl ManageDirectory for Store {
.details("ID assignment failed")
.caused_by(trc::location!()));
}
principal_create.id = principal_id;
create_principal.id = principal_id;
let mut batch = BatchBuilder::new();
let pinfo_name = PrincipalInfo::new(principal_id, principal_create.typ, tenant_id);
let pinfo_email = PrincipalInfo::new(principal_id, principal_create.typ, None);
let pinfo_name = PrincipalInfo::new(principal_id, create_principal.typ, tenant_id);
let pinfo_email = PrincipalInfo::new(principal_id, create_principal.typ, None);
// Validate object size
if principal_create.object_size() > 100_000 {
if create_principal.object_size() > 100_000 {
return Err(error(
"Invalid parameter",
"Principal object size exceeds 100kb safety limit.".into(),
@ -574,10 +584,10 @@ impl ManageDirectory for Store {
}
// Serialize
principal_create.sort();
let archiver = Archiver::new(principal_create);
create_principal.sort();
let archiver = Archiver::new(create_principal);
let principal_bytes = archiver.serialize().caused_by(trc::location!())?;
let principal_create = archiver.into_inner();
let create_principal = archiver.into_inner();
batch
.with_account_id(u32::MAX)
@ -585,11 +595,11 @@ impl ManageDirectory for Store {
.create_document(principal_id)
.assert_value(
ValueClass::Directory(DirectoryClass::NameToId(
principal_create.name().as_bytes().to_vec(),
create_principal.name().as_bytes().to_vec(),
)),
(),
);
build_search_index(&mut batch, principal_id, None, Some(&principal_create));
build_search_index(&mut batch, principal_id, None, Some(&create_principal));
batch
.set(
ValueClass::Directory(DirectoryClass::Principal(principal_id)),
@ -597,13 +607,13 @@ impl ManageDirectory for Store {
)
.set(
ValueClass::Directory(DirectoryClass::NameToId(
principal_create.name.as_bytes().to_vec(),
create_principal.name.as_bytes().to_vec(),
)),
pinfo_name.serialize(),
);
// Write email to id mapping
for email in principal_create.email_addresses() {
for email in create_principal.email_addresses() {
batch.set(
ValueClass::Directory(DirectoryClass::EmailToId(email.as_bytes().to_vec())),
pinfo_email.serialize(),
@ -633,7 +643,7 @@ impl ManageDirectory for Store {
principal_id: member.id,
member_of: principal_id,
}),
vec![principal_create.typ as u8],
vec![create_principal.typ as u8],
);
batch.set(
ValueClass::Directory(DirectoryClass::Members {
@ -1160,11 +1170,24 @@ impl ManageDirectory for Store {
) => {
// Password changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
principal
.data
.retain(|v| !matches!(v, PrincipalData::Secret(_)));
principal.data.retain(|v| {
!matches!(
v,
PrincipalData::Password(_)
| PrincipalData::AppPassword(_)
| PrincipalData::OtpAuth(_)
)
});
let mut has_secret = false;
for secret in value.into_str_array() {
principal.data.push(PrincipalData::Secret(secret));
if secret.is_otp_secret() {
principal.data.push(PrincipalData::OtpAuth(secret));
} else if secret.is_app_secret() {
principal.data.push(PrincipalData::AppPassword(secret));
} else if !has_secret {
has_secret = true;
principal.data.push(PrincipalData::Password(secret));
}
}
}
(
@ -1172,8 +1195,26 @@ impl ManageDirectory for Store {
PrincipalField::Secrets,
PrincipalValue::String(secret),
) => {
if !principal.secrets().any(|v| *v == secret) {
principal.data.push(PrincipalData::Secret(secret));
if !principal.data.iter().any(|v| match v {
PrincipalData::Password(v)
| PrincipalData::AppPassword(v)
| PrincipalData::OtpAuth(v) => *v == secret,
_ => false,
}) {
if secret.is_app_secret() {
principal.data.push(PrincipalData::AppPassword(secret));
} else if secret.is_otp_secret() {
principal
.data
.retain(|v| !matches!(v, PrincipalData::OtpAuth(_)));
principal.data.push(PrincipalData::OtpAuth(secret));
} else {
principal
.data
.retain(|v| !matches!(v, PrincipalData::Password(_)));
principal.data.push(PrincipalData::Password(secret));
}
// Password changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
}
@ -1186,22 +1227,21 @@ impl ManageDirectory for Store {
// Password changed, update changed principals
changed_principals.add_change(principal_id, principal_type, change.field);
if secret.is_app_password() || secret.is_otp_auth() {
if secret.is_app_secret() || secret.is_otp_secret() {
principal.data.retain(|v| match v {
PrincipalData::Secret(v) => {
PrincipalData::AppPassword(v) | PrincipalData::OtpAuth(v) => {
*v != secret && !v.starts_with(secret.as_str())
}
_ => true,
});
} else if !secret.is_empty() {
principal.data.retain(|v| match v {
PrincipalData::Secret(v) => *v != secret,
PrincipalData::Password(v) => *v != secret,
_ => true,
});
} else {
principal.data.retain(|v| match v {
PrincipalData::Secret(v) => !v.is_password(),
_ => true,
principal.data.retain(|v| {
!matches!(v, PrincipalData::AppPassword(_) | PrincipalData::OtpAuth(_))
});
}
}
@ -1319,7 +1359,7 @@ impl ManageDirectory for Store {
}
for email in principal.email_addresses() {
if !emails.contains(email) {
if !emails.iter().any(|v| v == email) {
batch.clear(ValueClass::Directory(DirectoryClass::EmailToId(
email.as_bytes().to_vec(),
)));
@ -1351,7 +1391,7 @@ impl ManageDirectory for Store {
let email = email.to_lowercase();
let mut emails_iter = principal.email_addresses().peekable();
let has_emails = emails_iter.peek().is_some();
let email_exists = emails_iter.any(|v| v == &email);
let email_exists = emails_iter.any(|v| v == email);
drop(emails_iter);
if !email_exists {
if validate_emails {
@ -1380,7 +1420,7 @@ impl ManageDirectory for Store {
PrincipalValue::String(email),
) => {
let email = email.to_lowercase();
if principal.email_addresses().any(|v| v == &email) {
if principal.email_addresses().any(|v| v == email) {
let mut deleted_primary = false;
principal.data.retain(|v| match v {
PrincipalData::EmailAlias(v) => v != &email,
@ -2320,7 +2360,9 @@ impl ManageDirectory for Store {
result.set(PrincipalField::Description, description);
}
}
PrincipalData::Secret(secret) => {
PrincipalData::Password(secret)
| PrincipalData::AppPassword(secret)
| PrincipalData::OtpAuth(secret) => {
if fields.is_empty() || fields.contains(&PrincipalField::Secrets) {
result.append_str(PrincipalField::Secrets, secret);
}

View file

@ -272,24 +272,19 @@ impl PrincipalField {
}
pub trait SpecialSecrets {
fn is_otp_auth(&self) -> bool;
fn is_app_password(&self) -> bool;
fn is_password(&self) -> bool;
fn is_otp_secret(&self) -> bool;
fn is_app_secret(&self) -> bool;
}
impl<T> SpecialSecrets for T
where
T: AsRef<str>,
{
fn is_otp_auth(&self) -> bool {
fn is_otp_secret(&self) -> bool {
self.as_ref().starts_with("otpauth://")
}
fn is_app_password(&self) -> bool {
fn is_app_secret(&self) -> bool {
self.as_ref().starts_with("$app$")
}
fn is_password(&self) -> bool {
!self.is_otp_auth() && !self.is_app_password()
}
}

View file

@ -4,23 +4,22 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry};
use mail_send::Credentials;
use store::xxhash_rust;
use trc::AddContext;
use super::{AuthBind, LdapDirectory, LdapMappings};
use crate::{
IntoError, Principal, PrincipalData, QueryBy, QueryParams, ROLE_ADMIN, ROLE_USER, Type,
backend::{
RcptType,
internal::{
SpecialSecrets,
lookup::DirectoryStore,
manage::{self, ManageDirectory, UpdatePrincipal},
},
},
};
use super::{AuthBind, LdapDirectory, LdapMappings};
use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry};
use mail_send::Credentials;
use store::xxhash_rust;
use trc::AddContext;
impl LdapDirectory {
pub async fn query(&self, by: QueryParams<'_>) -> trc::Result<Option<Principal>> {
@ -186,7 +185,7 @@ impl LdapDirectory {
AuthBind::None => {
let filter = self.mappings.filter_name.build(username);
if let Some(mut result) = self.find_principal(&mut conn, &filter).await? {
if result.principal.verify_secret(secret, false).await? {
if result.principal.verify_secret(secret, false, false).await? {
if result.principal.name.is_empty() {
result.principal.name = username.into();
}
@ -270,7 +269,7 @@ impl LdapDirectory {
};
// Keep the internal store up to date with the LDAP server
let changes = principal.update_external(external_principal, true);
let changes = principal.update_external(external_principal);
if !changes.is_empty() {
self.data_store
.update_principal(
@ -431,7 +430,10 @@ impl LdapMappings {
let mut role = ROLE_USER;
let mut member_of = vec![];
let mut description = None;
let mut has_primary_email = false;
let mut secret = None;
let mut otp_secret = None;
let mut email = None;
let mut email_aliases = Vec::new();
for (attr, value) in entry.attrs {
if self.attr_name.contains(&attr) {
@ -439,16 +441,10 @@ impl LdapMappings {
principal.name = value.into_iter().next().unwrap_or_default();
} else {
for (idx, item) in value.into_iter().enumerate() {
if !has_primary_email {
has_primary_email = true;
principal
.data
.push(PrincipalData::PrimaryEmail(item.to_lowercase()));
} else {
principal
.data
.push(PrincipalData::EmailAlias(item.to_lowercase()));
if email.is_none() {
email = Some(item.to_lowercase());
}
if idx == 0 {
principal.name = item;
}
@ -456,35 +452,33 @@ impl LdapMappings {
}
} else if self.attr_secret.contains(&attr) {
for item in value {
principal.data.push(PrincipalData::Secret(item));
if item.is_otp_secret() {
otp_secret = Some(item);
} else if item.is_app_secret() {
principal.data.push(PrincipalData::AppPassword(item));
} else if secret.is_none() {
secret = Some(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.data.push(PrincipalData::Secret(format!(
"$app${}$",
xxhash_rust::xxh3::xxh3_64(item.as_bytes())
)));
if secret.is_none() {
secret = value.into_iter().next().map(|item| {
format!("$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes()))
});
}
} else if self.attr_email_address.contains(&attr) {
for item in value {
if !has_primary_email {
has_primary_email = true;
principal
.data
.push(PrincipalData::PrimaryEmail(item.to_lowercase()));
if email.is_some() {
email_aliases.push(item.to_lowercase());
} else {
principal
.data
.push(PrincipalData::EmailAlias(item.to_lowercase()));
email = Some(item.to_lowercase());
}
}
} else if self.attr_email_alias.contains(&attr) {
for item in value {
principal
.data
.push(PrincipalData::EmailAlias(item.to_lowercase()));
email_aliases.push(item.to_lowercase());
}
} else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) {
if (description.is_none() || idx == 0)
@ -520,6 +514,24 @@ impl LdapMappings {
}
}
for alias in email_aliases {
if email.as_ref().is_none_or(|email| email != &alias) {
principal.data.push(PrincipalData::EmailAlias(alias));
}
}
if let Some(email) = email {
principal.data.push(PrincipalData::PrimaryEmail(email));
}
if let Some(secret) = secret {
principal.data.push(PrincipalData::Password(secret));
}
if let Some(otp_secret) = otp_secret {
principal.data.push(PrincipalData::OtpAuth(otp_secret));
}
if let Some(desc) = description {
principal.data.push(PrincipalData::Description(desc));
}

View file

@ -135,7 +135,7 @@ impl MemoryDirectory {
principal.name = name.as_str().into();
for (_, secret) in config.values((prefix.as_str(), "principals", lookup_id, "secret")) {
principal.data.push(PrincipalData::Secret(secret.into()));
principal.data.push(PrincipalData::Password(secret.into()));
}
if let Some(description) =
config.value((prefix.as_str(), "principals", lookup_id, "description"))

View file

@ -35,7 +35,7 @@ impl MemoryDirectory {
for principal in &self.principals {
if principal.name() == username {
return if principal.verify_secret(secret, false).await? {
return if principal.verify_secret(secret, false, false).await? {
Ok(Some(principal.clone()))
} else {
Ok(None)

View file

@ -104,7 +104,7 @@ impl OpenIdDirectory {
.ok_or_else(|| manage::not_found(id).caused_by(trc::location!()))?;
// Keep the internal store up to date with the OIDC server
let changes = principal.update_external(external_principal, false);
let changes = principal.update_external(external_principal);
if !changes.is_empty() {
self.data_store
.update_principal(

View file

@ -10,6 +10,7 @@ use crate::{
backend::{
RcptType,
internal::{
SpecialSecrets,
lookup::DirectoryStore,
manage::{self, ManageDirectory, UpdatePrincipal},
},
@ -99,17 +100,31 @@ impl SqlDirectory {
for row in secrets.rows {
for value in row.values {
if let Value::Text(text) = value {
principal
if let Value::Text(secret) = value {
let secret = secret.into_owned();
if secret.is_otp_secret() {
if !principal.data.iter().any(|data| {
matches!(data, PrincipalData::OtpAuth(_))
}) {
principal.data.push(PrincipalData::OtpAuth(secret));
}
} else if secret.is_app_secret() {
principal.data.push(PrincipalData::AppPassword(secret));
} else if !principal
.data
.push(PrincipalData::Secret(text.as_ref().into()));
.iter()
.any(|data| matches!(data, PrincipalData::Password(_)))
{
principal.data.push(PrincipalData::Password(secret));
}
}
}
}
}
if principal
.verify_secret(secret, false)
.verify_secret(secret, false, false)
.await
.caused_by(trc::location!())?
{
@ -201,7 +216,7 @@ impl SqlDirectory {
};
// Keep the internal store up to date with the SQL server
let changes = principal.update_external(external_principal, true);
let changes = principal.update_external(external_principal);
if !changes.is_empty() {
self.data_store
.update_principal(
@ -281,14 +296,13 @@ impl SqlMappings {
let mut principal = Principal::new(u32::MAX, Type::Individual);
let mut role = ROLE_USER;
let mut has_primary_email = false;
let mut secret = None;
if let Some(row) = rows.rows.into_iter().next() {
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
.data
.push(PrincipalData::Secret(text.as_ref().into()));
secret = Some(text.into_owned());
}
} else if name.eq_ignore_ascii_case(&self.column_type) {
match value.to_str().as_ref() {
@ -330,6 +344,10 @@ impl SqlMappings {
}
}
if let Some(secret) = secret {
principal.data.push(PrincipalData::Password(secret));
}
principal.data.push(PrincipalData::Role(role));
Ok(Some(principal))

View file

@ -7,9 +7,7 @@
use crate::{
ArchivedPrincipal, ArchivedPrincipalData, FALLBACK_ADMIN_ID, Permission, PermissionGrant,
Principal, PrincipalData, ROLE_ADMIN, Type,
backend::internal::{
PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue, SpecialSecrets,
},
backend::internal::{PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue},
};
use ahash::AHashSet;
use nlp::tokenizers::word::WordTokenizer;
@ -19,7 +17,7 @@ use serde::{
ser::SerializeMap,
};
use serde_json::Value;
use std::{collections::hash_map::Entry, fmt, str::FromStr};
use std::{cmp::Ordering, collections::hash_map::Entry, fmt, str::FromStr};
use store::{
U32_LEN, U64_LEN,
backend::MAX_TOKEN_LENGTH,
@ -105,25 +103,14 @@ impl Principal {
})
}
pub fn secrets(&self) -> impl Iterator<Item = &String> {
let mut found_secret = false;
self.data
.iter()
.take_while(move |item| {
if matches!(item, PrincipalData::Secret(_)) {
found_secret = true;
true
} else {
!found_secret
}
})
.filter_map(|item| {
if let PrincipalData::Secret(secret) = item {
Some(secret)
} else {
None
}
})
pub fn secret(&self) -> Option<&str> {
if let Some(PrincipalData::Password(password)) = self.data.first() {
Some(password.as_str())
} else if let Some(PrincipalData::Password(password)) = self.data.get(1) {
Some(password.as_str())
} else {
None
}
}
pub fn primary_email(&self) -> Option<&str> {
@ -136,7 +123,7 @@ impl Principal {
})
}
pub fn email_addresses(&self) -> impl Iterator<Item = &String> {
pub fn email_addresses(&self) -> impl Iterator<Item = &str> {
let mut found_email = false;
self.data
.iter()
@ -154,7 +141,7 @@ impl Principal {
.filter_map(|item| {
if let PrincipalData::PrimaryEmail(email) | PrincipalData::EmailAlias(email) = item
{
Some(email)
Some(email.as_str())
} else {
None
}
@ -315,124 +302,157 @@ impl Principal {
});
}
pub fn update_external(
&mut self,
external: Principal,
overwrite_emails: bool,
) -> Vec<PrincipalUpdate> {
pub fn update_external(&mut self, external: Principal) -> Vec<PrincipalUpdate> {
let mut updates = Vec::new();
let mut external_data = AHashSet::with_capacity(external.data.len());
let mut has_role = false;
let mut has_member_of = false;
let mut has_quota = false;
// Add external members
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
for (idx, role) in external.roles().enumerate() {
if idx == 0 && self.roles().next().is_some() {
break;
}
self.data.push(PrincipalData::Role(role));
}
// 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(_)));
for item in external.data {
match item {
PrincipalData::DiskQuota(_) => {
has_quota = true;
external_data.insert(item);
}
self.data
.push(PrincipalData::Description(external.to_string()));
updates.push(PrincipalUpdate::set(
PrincipalField::Description,
PrincipalValue::String(external.to_string()),
));
PrincipalData::MemberOf(_) => {
has_member_of = true;
external_data.insert(item);
}
PrincipalData::Role(_) => {
has_role = true;
external_data.insert(item);
}
PrincipalData::Password(_)
| PrincipalData::AppPassword(_)
| PrincipalData::OtpAuth(_)
| PrincipalData::Description(_)
| PrincipalData::PrimaryEmail(_)
| PrincipalData::EmailAlias(_) => {
external_data.insert(item);
}
_ => {}
}
_ => {}
}
// 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(new_secrets),
));
}
let mut old_data = Vec::new();
let data_len = self.data.len();
// Update emails
if update_list(external.email_addresses(), self.email_addresses()) {
if overwrite_emails {
let mut new_emails = Vec::new();
self.data.retain(|item| {
!matches!(
for item in std::mem::replace(&mut self.data, Vec::with_capacity(data_len)) {
match item {
PrincipalData::Password(_)
| PrincipalData::AppPassword(_)
| PrincipalData::OtpAuth(_)
| PrincipalData::Description(_)
| PrincipalData::PrimaryEmail(_)
| PrincipalData::EmailAlias(_)
| PrincipalData::DiskQuota(_)
| PrincipalData::MemberOf(_)
| PrincipalData::Role(_) => {
if external_data.remove(&item)
|| match item {
PrincipalData::EmailAlias(_) => true,
PrincipalData::Role(_) => !has_role,
PrincipalData::MemberOf(_) => !has_member_of,
PrincipalData::DiskQuota(_) => !has_quota,
_ => false,
}
{
self.data.push(item);
} else if matches!(
item,
PrincipalData::PrimaryEmail(_) | PrincipalData::EmailAlias(_)
)
});
self.data
.extend(external.data.iter().filter_map(|v| match v {
PrincipalData::PrimaryEmail(email) => {
new_emails.push(email.clone());
Some(PrincipalData::PrimaryEmail(email.clone()))
}
PrincipalData::EmailAlias(email) => {
new_emails.push(email.clone());
Some(PrincipalData::EmailAlias(email.clone()))
}
_ => None,
}));
updates.push(PrincipalUpdate::set(
PrincipalField::Emails,
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
let current_emails = self.email_addresses().collect::<AHashSet<_>>();
let mut new_emails = Vec::new();
for email in external.email_addresses() {
let email = email.to_lowercase();
if !current_emails.contains(&email) {
updates.push(PrincipalUpdate::add_item(
PrincipalField::Emails,
PrincipalValue::String(email.clone()),
));
new_emails.push(PrincipalData::EmailAlias(email));
PrincipalData::Password(_)
| PrincipalData::AppPassword(_)
| PrincipalData::OtpAuth(_)
| PrincipalData::PrimaryEmail(_)
| PrincipalData::EmailAlias(_)
) {
old_data.push(item);
}
}
self.data.extend(new_emails);
_ => {
self.data.push(item);
}
}
}
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(_)));
// Add new data
let mut has_password = false;
let mut has_email = false;
for item in external_data {
match &item {
PrincipalData::Description(value) => {
updates.push(PrincipalUpdate::set(
PrincipalField::Description,
PrincipalValue::String(value.to_string()),
));
}
PrincipalData::DiskQuota(value) => {
updates.push(PrincipalUpdate::set(
PrincipalField::Quota,
PrincipalValue::Integer(*value),
));
}
PrincipalData::Password(value)
| PrincipalData::AppPassword(value)
| PrincipalData::OtpAuth(value) => {
let item = PrincipalUpdate::add_item(
PrincipalField::Secrets,
PrincipalValue::String(value.to_string()),
);
if !has_password && !updates.is_empty() {
updates.insert(0, item);
} else {
updates.push(item);
}
has_password = true;
}
PrincipalData::PrimaryEmail(value) => {
let item = PrincipalUpdate::add_item(
PrincipalField::Emails,
PrincipalValue::String(value.to_string()),
);
if !has_email && !updates.is_empty() {
updates.insert(0, item);
} else {
updates.push(item);
}
has_email = true;
}
PrincipalData::EmailAlias(value) => {
updates.push(PrincipalUpdate::add_item(
PrincipalField::Emails,
PrincipalValue::String(value.to_string()),
));
}
_ => (),
}
self.data.push(PrincipalData::DiskQuota(external_quota));
updates.push(PrincipalUpdate::set(
PrincipalField::Quota,
PrincipalValue::Integer(external_quota),
));
self.data.push(item);
}
// Remove old data
for item in old_data {
match item {
PrincipalData::Password(value)
| PrincipalData::AppPassword(value)
| PrincipalData::OtpAuth(value) => {
updates.push(PrincipalUpdate::remove_item(
PrincipalField::Secrets,
PrincipalValue::String(value),
));
}
PrincipalData::PrimaryEmail(value) | PrincipalData::EmailAlias(value) => {
updates.push(PrincipalUpdate::remove_item(
PrincipalField::Emails,
PrincipalValue::String(value),
));
}
_ => (),
}
}
self.sort();
updates
}
@ -452,66 +472,64 @@ impl Principal {
name: "Fallback Administrator".into(),
data: vec![
PrincipalData::Role(ROLE_ADMIN),
PrincipalData::Secret(fallback_pass.into()),
PrincipalData::Password(fallback_pass.into()),
],
}
}
pub fn sort(&mut self) {
self.data.sort_unstable_by_key(|d| d.rank());
self.data.sort_unstable();
}
}
impl PrincipalData {
fn rank(&self) -> usize {
fn rank(&self) -> u8 {
match self {
PrincipalData::Secret(v) => {
if v.is_otp_auth() {
0
} else {
1
}
}
PrincipalData::PrimaryEmail(_) => 2,
PrincipalData::EmailAlias(_) => 3,
_ => 4,
PrincipalData::OtpAuth(_) => 0,
PrincipalData::Password(_) => 1,
PrincipalData::AppPassword(_) => 2,
PrincipalData::PrimaryEmail(_) => 3,
PrincipalData::EmailAlias(_) => 4,
_ => 5,
}
}
fn rank_string(&self) -> Option<&str> {
match self {
PrincipalData::OtpAuth(s)
| PrincipalData::Password(s)
| PrincipalData::AppPassword(s)
| PrincipalData::PrimaryEmail(s)
| PrincipalData::EmailAlias(s) => Some(s),
_ => None,
}
}
}
fn update_list<'x>(
new: impl Iterator<Item = &'x String>,
mut current: impl Iterator<Item = &'x String>,
) -> 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;
}
}
impl PartialOrd for PrincipalData {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for PrincipalData {
fn cmp(&self, other: &Self) -> Ordering {
match self.rank().cmp(&other.rank()) {
Ordering::Equal => match (self.rank_string(), other.rank_string()) {
(Some(a), Some(b)) => a.cmp(b),
_ => Ordering::Equal,
},
other => other,
}
} else {
false
}
}
impl PrincipalData {
pub fn object_size(&self) -> usize {
match self {
PrincipalData::Secret(v)
PrincipalData::Password(v)
| PrincipalData::AppPassword(v)
| PrincipalData::OtpAuth(v)
| PrincipalData::Description(v)
| PrincipalData::PrimaryEmail(v)
| PrincipalData::EmailAlias(v)

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::Principal;
use crate::PrincipalData;
use argon2::Argon2;
use compact_str::ToCompactString;
use mail_builder::encoders::base64::base64_encode;
@ -19,91 +21,80 @@ use sha2::Sha512;
use tokio::sync::oneshot;
use totp_rs::TOTP;
use crate::Principal;
use crate::backend::internal::SpecialSecrets;
impl Principal {
pub async fn verify_secret(&self, mut code: &str, only_app_pass: bool) -> trc::Result<bool> {
let mut totp_token = None;
let mut is_totp_token_missing = false;
let mut is_totp_required = false;
let mut is_totp_verified = false;
let mut is_authenticated = false;
let mut is_app_authenticated = false;
pub async fn verify_secret(
&self,
code: &str,
only_app_pass: bool,
is_ordered: bool,
) -> trc::Result<bool> {
let mut seen_password = false;
let mut password = None;
let mut otp_auth = None;
for secret in self.secrets() {
if secret.is_otp_auth() {
if !is_totp_verified && !is_totp_token_missing {
is_totp_required = true;
let totp_token = if let Some(totp_token) = totp_token {
totp_token
} else if let Some((_code, _totp_token)) =
code.rsplit_once('$').filter(|(c, t)| {
!c.is_empty()
&& (6..=8).contains(&t.len())
&& t.as_bytes().iter().all(|b| b.is_ascii_digit())
})
{
totp_token = Some(_totp_token);
code = _code;
_totp_token
} else {
is_totp_token_missing = true;
continue;
};
// Token needs to validate with at least one of the TOTP secrets
is_totp_verified = TOTP::from_url(secret)
.map_err(|err| {
trc::AuthEvent::Error
.reason(err)
.details(secret.to_compact_string())
})?
.check_current(totp_token)
.unwrap_or(false);
for item in &self.data {
match item {
PrincipalData::OtpAuth(secret) => {
if !only_app_pass {
otp_auth = Some(secret);
}
seen_password = true;
}
} else if !is_authenticated && !is_app_authenticated {
if let Some((_, app_secret)) =
secret.strip_prefix("$app$").and_then(|s| s.split_once('$'))
{
is_app_authenticated = verify_secret_hash(app_secret, code).await?;
} else if !only_app_pass {
is_authenticated = verify_secret_hash(secret, code).await?;
PrincipalData::Password(secret) => {
if !only_app_pass {
password = Some(secret);
}
seen_password = true;
}
PrincipalData::AppPassword(secret) => {
// App passwords do not require TOTP
if let Some((_, app_secret)) =
secret.strip_prefix("$app$").and_then(|s| s.split_once('$'))
&& verify_secret_hash(app_secret, code).await?
{
return Ok(true);
}
seen_password = true;
}
_ => {
if seen_password && is_ordered {
// Password-related secrets are expected to be at the beginning of the list
break;
}
}
}
}
if is_authenticated {
if !is_totp_required {
// Authenticated without TOTP enabled
// Validate TOTP
match (otp_auth, password) {
(Some(otp_auth), Some(password)) => {
if let Some((code, totp_token)) = code.rsplit_once('$').filter(|(c, t)| {
!c.is_empty()
&& (6..=8).contains(&t.len())
&& t.as_bytes().iter().all(|b| b.is_ascii_digit())
}) {
let result = verify_secret_hash(password, code).await?
&& TOTP::from_url(otp_auth)
.map_err(|err| {
trc::AuthEvent::Error
.reason(err)
.details(otp_auth.to_compact_string())
})?
.check_current(totp_token)
.unwrap_or(false);
Ok(result)
} else if verify_secret_hash(password, code).await? {
// Only let the client know if the TOTP code is missing
// if the password is correct
Ok(true)
} else if is_totp_token_missing {
// Only let the client know if the TOTP code is missing
// if the password is correct
Err(trc::AuthEvent::MissingTotp.into_err())
} else {
// Return the TOTP verification status
Ok(is_totp_verified)
}
} else if is_app_authenticated {
// App passwords do not require TOTP
Ok(true)
} else {
if is_totp_verified {
// TOTP URL appeared after password hash in secrets list
for secret in self.secrets() {
if secret.is_password() && verify_secret_hash(secret, code).await? {
return Ok(true);
}
Err(trc::AuthEvent::MissingTotp.into_err())
} else {
Ok(false)
}
}
Ok(false)
(None, Some(password)) => verify_secret_hash(password, code).await,
_ => Ok(false),
}
}
}

View file

@ -42,9 +42,9 @@ pub struct Principal {
pub data: Vec<PrincipalData>,
}
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)]
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq, Hash)]
pub enum PrincipalData {
Secret(String),
Password(String),
// Permissions and memberships
Tenant(u32),
@ -66,6 +66,10 @@ pub enum PrincipalData {
ExternalMember(String),
Url(String),
Locale(String),
// Secrets
AppPassword(String),
OtpAuth(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -92,6 +96,7 @@ pub struct MemberOf {
Eq,
serde::Serialize,
serde::Deserialize,
Hash,
)]
#[serde(rename_all = "camelCase")]
pub enum Type {

View file

@ -6,10 +6,9 @@
use common::{KV_BAYES_MODEL_USER, Server, auth::AccessToken};
use directory::{
DirectoryInner, Permission, QueryBy, QueryParams, Type,
DirectoryInner, Permission, PrincipalData, QueryBy, QueryParams, Type,
backend::internal::{
PrincipalAction, PrincipalField, PrincipalSet, PrincipalUpdate, PrincipalValue,
SpecialSecrets,
lookup::DirectoryStore,
manage::{
self, ChangedPrincipals, ManageDirectory, PrincipalList, UpdatePrincipal, not_found,
@ -710,13 +709,19 @@ impl PrincipalManager for Server {
.await?
.ok_or_else(|| trc::ManageEvent::NotFound.into_err())?;
for secret in principal.secrets() {
if secret.is_otp_auth() {
response.otp_auth = true;
} else if let Some((app_name, _)) =
secret.strip_prefix("$app$").and_then(|s| s.split_once('$'))
{
response.app_passwords.push(app_name.into());
for data in &principal.data {
match data {
PrincipalData::OtpAuth(_) => {
response.otp_auth = true;
}
PrincipalData::AppPassword(secret) => {
if let Some((app_name, _)) =
secret.strip_prefix("$app$").and_then(|s| s.split_once('$'))
{
response.app_passwords.push(app_name.into());
}
}
_ => {}
}
}
}

View file

@ -68,7 +68,7 @@ impl IdentitySet for Server {
.directory()
.query(QueryParams::id(account_id).with_return_member_of(false))
.await?
.is_none_or(|p| !p.email_addresses().any(|e| e == &identity.email))
.is_none_or(|p| !p.email_addresses().any(|e| e == identity.email))
{
response.not_created.append(
id,

View file

@ -12,7 +12,7 @@ use crate::{
use common::Server;
use directory::{
Permission, Principal, PrincipalData, ROLE_ADMIN, ROLE_USER, Type,
backend::internal::{PrincipalField, PrincipalSet},
backend::internal::{PrincipalField, PrincipalSet, SpecialSecrets},
};
use nlp::tokenizers::word::WordTokenizer;
use std::{slice::Iter, time::Instant};
@ -183,11 +183,19 @@ impl FromLegacy for Principal {
};
// Map fields
let mut has_secret = false;
for secret in legacy
.take_str_array(PrincipalField::Secrets)
.unwrap_or_default()
{
principal.data.push(PrincipalData::Secret(secret));
if secret.is_otp_secret() {
principal.data.push(PrincipalData::OtpAuth(secret));
} else if secret.is_app_secret() {
principal.data.push(PrincipalData::AppPassword(secret));
} else if !has_secret {
principal.data.push(PrincipalData::Password(secret));
has_secret = true;
}
}
for (idx, email) in legacy
.take_str_array(PrincipalField::Emails)
@ -371,7 +379,7 @@ pub(crate) fn build_search_index(batch: &mut BatchBuilder, principal_id: u32, ne
for word in [Some(new.name.as_str()), new.description()]
.into_iter()
.chain(new.email_addresses().map(|s| Some(s.as_str())))
.chain(new.email_addresses().map(Some))
.flatten()
{
new_words.extend(WordTokenizer::new(word, MAX_TOKEN_LENGTH).map(|t| t.word));

View file

@ -7,7 +7,7 @@
use std::time::Instant;
use common::Server;
use directory::{Principal, PrincipalData, Type};
use directory::{Principal, PrincipalData, Type, backend::internal::SpecialSecrets};
use proc_macros::EnumMethods;
use store::{
Serialize, ValueKey,
@ -59,8 +59,16 @@ pub(crate) async fn migrate_principals_v0_13(server: &Server) -> trc::Result<Roa
data: Vec::new(),
};
let mut has_secret = false;
for secret in old_principal.secrets {
principal.data.push(PrincipalData::Secret(secret));
if secret.is_otp_secret() {
principal.data.push(PrincipalData::OtpAuth(secret));
} else if secret.is_app_secret() {
principal.data.push(PrincipalData::AppPassword(secret));
} else if !has_secret {
principal.data.push(PrincipalData::Password(secret));
has_secret = true;
}
}
for (idx, email) in old_principal.emails.into_iter().enumerate() {

View file

@ -432,19 +432,19 @@ mod tests {
// sort by name
names.sort_by(|a, b| a.0.cmp(b.0));
/*for (name, description, level) in names {
for (name, description, level) in names {
//println!("{:?},", name);
println!("|`{name}`|{description}|`{level}`|")
}*/
}
for (pos, (name, _, _)) in names.iter().enumerate() {
//for (pos, (name, _, _)) in names.iter().enumerate() {
//println!("{:?},", name);
println!("{} => Some({}),", pos, event_to_class(name));
//println!("{} => Some({}),", pos, event_to_class(name));
//println!("{} => {},", event_to_class(name), pos);
/*println!(
"#[serde(rename = \"{name}\")]\n{},",
event_to_webadmin_class(name)
);*/
}
//}
}
}

View file

@ -47,7 +47,7 @@ async fn internal_directory() {
TestPrincipal {
name: "john".into(),
description: Some("John Doe".into()),
secrets: vec!["secret".into(), "secret2".into()],
secrets: vec!["secret".into(), "$app$secret2".into()],
..Default::default()
}
.into(),
@ -148,7 +148,7 @@ async fn internal_directory() {
TestPrincipal {
name: "jane".into(),
description: Some("Jane Doe".into()),
secrets: vec!["my_secret".into(), "my_secret2".into()],
secrets: vec!["my_secret".into(), "$app$my_secret2".into()],
emails: vec!["jane@example.org".into()],
quota: 123,
..Default::default()
@ -188,7 +188,7 @@ async fn internal_directory() {
name: "jane".into(),
description: Some("Jane Doe".into()),
emails: vec!["jane@example.org".into()],
secrets: vec!["my_secret".into(), "my_secret2".into()],
secrets: vec!["my_secret".into(), "$app$my_secret2".into()],
quota: 123,
..Default::default()
})
@ -366,7 +366,7 @@ async fn internal_directory() {
id: john_id,
name: "john".into(),
description: Some("John Doe".into()),
secrets: vec!["secret".into(), "secret2".into()],
secrets: vec!["secret".into(), "$app$secret2".into()],
emails: vec!["john@example.org".into()],
member_of: vec!["sales".into(), "support".into()],
lists: vec!["list".into()],
@ -411,7 +411,7 @@ async fn internal_directory() {
id: john_id,
name: "john".into(),
description: Some("John Doe".into()),
secrets: vec!["secret".into(), "secret2".into()],
secrets: vec!["secret".into(), "$app$secret2".into()],
emails: vec!["john@example.org".into()],
member_of: vec!["sales".into()],
lists: vec!["list".into()],

View file

@ -13,7 +13,7 @@ pub mod sql;
use common::{Core, Server, config::smtp::session::AddressMapping};
use directory::{
Directories, Principal, Type,
Directories, Principal, PrincipalData, Type,
backend::internal::{PrincipalField, PrincipalSet, manage::ManageDirectory},
};
use mail_send::Credentials;
@ -594,7 +594,16 @@ impl From<Principal> for TestPrincipal {
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(),
secrets: value
.data
.iter()
.filter_map(|v| match v {
PrincipalData::Password(s)
| PrincipalData::AppPassword(s)
| PrincipalData::OtpAuth(s) => Some(s.to_string()),
_ => None,
})
.collect(),
emails: value.email_addresses().map(|v| v.to_string()).collect(),
description: value.description().map(|v| v.to_string()),
name: value.name,

View file

@ -131,10 +131,7 @@ async fn oidc_directory() {
.unwrap()
.unwrap();
assert_eq!(principal.name(), "jdoe");
assert_eq!(
principal.email_addresses().next().map(|s| s.as_str()),
Some("john@example.org")
);
assert_eq!(principal.email_addresses().next(), Some("john@example.org"));
assert_eq!(principal.description(), Some("John Doe"));
}
}

View file

@ -5,7 +5,7 @@
*/
use directory::{
QueryParams, ROLE_USER, Type,
QueryParams, ROLE_ADMIN, ROLE_USER, Type,
backend::{RcptType, internal::manage::ManageDirectory},
};
use mail_send::Credentials;
@ -189,7 +189,7 @@ async fn sql_directory() {
description: Some("Administrator".into()),
secrets: vec!["very_secret".into()],
typ: Type::Individual,
roles: vec![ROLE_USER.to_string()],
roles: vec![ROLE_ADMIN.to_string()],
..Default::default()
}
);
@ -208,13 +208,15 @@ async fn sql_directory() {
);
// Get user by name
let mut p = handle
.query(QueryParams::name("jane").with_return_member_of(true))
.await
.unwrap()
.unwrap()
.into_test();
p.member_of.sort();
assert_eq!(
handle
.query(QueryParams::name("jane").with_return_member_of(true))
.await
.unwrap()
.unwrap()
.into_test(),
p,
TestPrincipal {
id: base_store.get_principal_id("jane").await.unwrap().unwrap(),
name: "jane".into(),