diff --git a/Cargo.lock b/Cargo.lock index 8800bb0f..d4588af5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1894,6 +1894,7 @@ dependencies = [ "compact_str", "dav-proto", "directory", + "email", "groupware", "hashify", "http_proto", diff --git a/crates/dav/Cargo.toml b/crates/dav/Cargo.toml index 08b0390b..3af82894 100644 --- a/crates/dav/Cargo.toml +++ b/crates/dav/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] dav-proto = { path = "../dav-proto" } common = { path = "../common" } +email = { path = "../email" } store = { path = "../store" } utils = { path = "../utils" } groupware = { path = "../groupware" } diff --git a/crates/dav/src/common/acl.rs b/crates/dav/src/common/acl.rs index ede723a1..1e1399ef 100644 --- a/crates/dav/src/common/acl.rs +++ b/crates/dav/src/common/acl.rs @@ -20,6 +20,7 @@ use dav_proto::{ response::{Ace, BaseCondition, GrantDeny, Href, MultiStatus, Principal}, }, }; +use email::known_accounts::KnownAccounts; use groupware::RFC_3986; use groupware::{cache::GroupwareCache, calendar::Calendar, contact::AddressBook, file::FileNode}; use http_proto::HttpResponse; @@ -175,6 +176,28 @@ impl DavAclHandler for Server { } } + // inbuxa: with directory queries off, nobody new can be added, or a + // grant followed by reading the ACL would name any account. + if !self.can_read_directory(access_token) + && grants + .iter() + .any(|g| !acls.iter().any(|a| u32::from(a.account_id) == g.account_id)) + { + let known = self + .known_accounts(access_token) + .await + .caused_by(trc::location!())?; + if grants.iter().any(|g| { + !known.contains(g.account_id) + && !acls.iter().any(|a| u32::from(a.account_id) == g.account_id) + }) { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::AllowedPrincipal, + ))); + } + } + if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) { // Refresh ACLs self.refresh_archived_acls(&grants, acls) diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs index c90435e3..e28b4562 100644 --- a/crates/dav/src/request.rs +++ b/crates/dav/src/request.rs @@ -218,7 +218,8 @@ impl DavRequestDispatcher for Server { Report::AclPrincipalPropSet(report) => { // inbuxa: not a directory read, so allowDirectoryQueries // doesn't apply: the item's owner or a sharer, and only - // the accounts already on its ACL. + // the accounts on its ACL, which that setting also limits + // when they're added (see handle_acl_request and acl_validate). let access_token = access_token.assert_has_permission(Permission::DavPrincipalAcl)?; diff --git a/crates/email/src/known_accounts.rs b/crates/email/src/known_accounts.rs new file mode 100644 index 00000000..adf8c180 --- /dev/null +++ b/crates/email/src/known_accounts.rs @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! With directory queries off, who an account may still see and share with: +//! the accounts it already has to do with. Reading names and granting access +//! use the same list, so a grant can't be used to learn a name it couldn't +//! read otherwise. + +use crate::cache::MessageCacheFetch; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use groupware::cache::GroupwareCache; +use std::future::Future; +use store::roaring::RoaringBitmap; +use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::{Collection, SyncCollection}, +}; + +pub trait KnownAccounts: Sync + Send { + /// The account itself, its groups, whoever shared with it, and whoever is + /// on an item it manages. + fn known_accounts( + &self, + access_token: &AccessToken, + ) -> impl Future> + Send; +} + +impl KnownAccounts for Server { + async fn known_accounts(&self, access_token: &AccessToken) -> trc::Result { + let mut known = RoaringBitmap::from_iter(access_token.all_ids()); + let mut add = |account_id: u32, grants: &[AclGrant]| { + if !grants.is_empty() + && (access_token.is_member(account_id) + || grants.effective_acl(access_token).contains(Acl::Share)) + { + known.extend(grants.iter().map(|grant| grant.account_id)); + } + }; + + for (collection, sync_collection) in [ + (Collection::Calendar, SyncCollection::Calendar), + (Collection::AddressBook, SyncCollection::AddressBook), + (Collection::FileNode, SyncCollection::FileNode), + ] { + let account_ids = access_token + .all_ids_by_collection(collection) + .collect::(); + for account_id in account_ids { + let resources = self + .fetch_dav_resources(access_token.account_id(), account_id, sync_collection) + .await + .caused_by(trc::location!())?; + for resource in &resources.resources { + if let Some(grants) = resource.acls() { + add(account_id, grants); + } + } + } + } + + let account_ids = access_token + .all_ids_by_collection(Collection::Mailbox) + .collect::(); + for account_id in account_ids { + let cache = self + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + for mailbox in cache.mailboxes.items.iter() { + add(account_id, mailbox.acls.as_slice()); + } + } + + Ok(known) + } +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index a0ce9d1d..240f53bb 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -14,6 +14,7 @@ pub mod cache; pub mod identity; pub mod inbuxa_lock; // inbuxa: account lock grants +pub mod known_accounts; // inbuxa: directory queries off pub mod mailbox; pub mod message; pub mod push; diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index 05e7cca4..84501ae1 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -16,6 +16,7 @@ use common::{ storage::index::ObjectIndexBuilder, }; use compact_str::ToCompactString; +use email::known_accounts::KnownAccounts; use imap_proto::{ Command, ResponseCode, StatusResponse, protocol::acl::{ @@ -327,6 +328,29 @@ impl Session { .caused_by(trc::location!())); } + // inbuxa: with directory queries off, nobody new can be added, or + // SETACL followed by GETACL would name any account. Refused as if + // the account didn't exist. + if !data.server.can_read_directory(&access_token) + && !current_mailbox + .inner + .acls + .iter() + .any(|grant| grant.account_id == acl_account_id) + && !data + .server + .known_accounts(&access_token) + .await + .imap_ctx(&arguments.tag, trc::location!())? + .contains(acl_account_id) + { + return Err(trc::ImapEvent::Error + .into_err() + .details("Account does not exist") + .id(arguments.tag.to_string()) + .caused_by(trc::location!())); + } + // Prepare changes let mut mailbox = current_mailbox.inner.clone(); let (op, rights) = arguments diff --git a/crates/jmap/src/addressbook/set.rs b/crates/jmap/src/addressbook/set.rs index 09f36b64..33e4197b 100644 --- a/crates/jmap/src/addressbook/set.rs +++ b/crates/jmap/src/addressbook/set.rs @@ -114,7 +114,15 @@ impl AddressBookSet for Server { } // Validate ACLs if !address_book.acls.is_empty() { - if let Err(err) = self.acl_validate(account_id, &address_book.acls).await { + if let Err(err) = self + .acl_validate( + access_token, + account_id, + &address_book.acls, + std::iter::empty(), + ) + .await + { response.not_created.append(id, err.into()); continue 'create; } @@ -245,7 +253,19 @@ impl AddressBookSet for Server { continue 'update; } if has_acl_changes { - if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await { + if let Err(err) = self + .acl_validate( + access_token, + account_id, + &new_address_book.acls, + address_book + .inner + .acls + .iter() + .map(|grant| u32::from(grant.account_id)), + ) + .await + { response.not_updated.append(id, err.into()); continue 'update; } diff --git a/crates/jmap/src/api/acl.rs b/crates/jmap/src/api/acl.rs index 73f389ef..5ce68fa5 100644 --- a/crates/jmap/src/api/acl.rs +++ b/crates/jmap/src/api/acl.rs @@ -7,6 +7,7 @@ */ use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use email::known_accounts::KnownAccounts; use jmap_proto::{ error::set::{SetError, SetErrorType}, object::{JmapRight, JmapSharedObject}, @@ -244,10 +245,14 @@ impl JmapRights { } pub trait JmapAcl { + /// `previous` holds the accounts already granted access before this change: + /// the item's current ACL, or the parent's when the ACL is inherited. fn acl_validate( &self, + access_token: &AccessToken, account_id: u32, grants: &[AclGrant], + previous: impl Iterator + Send, ) -> impl Future> + Send; } @@ -259,9 +264,13 @@ pub enum ShareValidationError { impl JmapAcl for Server { async fn acl_validate( &self, + access_token: &AccessToken, account_id: u32, grants: &[AclGrant], + previous: impl Iterator + Send, ) -> Result<(), ShareValidationError> { + let previous = previous.collect::(); + if grants.len() > self.core.groupware.max_shares_per_item { return Err(ShareValidationError::MaxSharesExceeded( self.core.groupware.max_shares_per_item, @@ -282,6 +291,23 @@ impl JmapAcl for Server { .flatten() .and_then(|owner| owner.id_tenant); + // inbuxa: with directory queries off, nobody new can be added, or a + // grant followed by reading the ACL would name any account. Same error + // as an account that doesn't exist, so it doesn't confirm one either. + if !self.can_read_directory(access_token) + && grants.iter().any(|g| !previous.contains(g.account_id)) + { + let known = self.known_accounts(access_token).await.unwrap_or_default(); + if let Some(grant) = grants + .iter() + .find(|g| !previous.contains(g.account_id) && !known.contains(g.account_id)) + { + return Err(ShareValidationError::InvalidAccountId(Id::from( + grant.account_id, + ))); + } + } + for grant in grants { if !principal_ids.contains(grant.account_id) || self diff --git a/crates/jmap/src/calendar/set.rs b/crates/jmap/src/calendar/set.rs index a2bd529d..12a9d723 100644 --- a/crates/jmap/src/calendar/set.rs +++ b/crates/jmap/src/calendar/set.rs @@ -118,7 +118,10 @@ impl CalendarSet for Server { } // Validate ACLs if !calendar.acls.is_empty() { - if let Err(err) = self.acl_validate(account_id, &calendar.acls).await { + if let Err(err) = self + .acl_validate(access_token, account_id, &calendar.acls, std::iter::empty()) + .await + { response.not_created.append(id, err.into()); continue 'create; } @@ -249,7 +252,19 @@ impl CalendarSet for Server { continue 'update; } if has_acl_changes { - if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await { + if let Err(err) = self + .acl_validate( + access_token, + account_id, + &new_calendar.acls, + calendar + .inner + .acls + .iter() + .map(|grant| u32::from(grant.account_id)), + ) + .await + { response.not_updated.append(id, err.into()); continue 'update; } diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index 619a3b77..c294a6df 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -417,7 +417,20 @@ impl FileNodeCopy for Server { } if !file_node.acls.is_empty() { - if let Err(err) = self.acl_validate(account_id, &file_node.acls).await { + if let Err(err) = self + .acl_validate( + access_token, + account_id, + &file_node.acls, + // inbuxa: inherited from the parent, so not newly granted + file_node + .acls + .iter() + .filter(|_| !has_acl_changes) + .map(|grant| grant.account_id), + ) + .await + { response.not_created.append(id, err.into()); continue 'create; } diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index 394a3668..9534f0ad 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -337,7 +337,20 @@ impl FileNodeSet for Server { // Validate ACLs if !file_node.acls.is_empty() { - if let Err(err) = self.acl_validate(account_id, &file_node.acls).await { + if let Err(err) = self + .acl_validate( + access_token, + account_id, + &file_node.acls, + // inbuxa: inherited from the parent, so not newly granted + file_node + .acls + .iter() + .filter(|_| !has_acl_changes) + .map(|grant| grant.account_id), + ) + .await + { response.not_created.append(id, err.into()); continue 'create; } @@ -628,7 +641,19 @@ impl FileNodeSet for Server { } if has_acl_changes { - if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await { + if let Err(err) = self + .acl_validate( + access_token, + account_id, + &new_file_node.acls, + file_node + .inner + .acls + .iter() + .map(|grant| u32::from(grant.account_id)), + ) + .await + { response.not_updated.append(id, err.into()); continue 'update; } diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index db455667..3e9c38bc 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -662,7 +662,14 @@ impl MailboxSet for Server { } if !changes.acls.is_empty() - && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await + && let Err(err) = self + .acl_validate( + ctx.access_token, + ctx.account_id, + &changes.acls, + before.iter().map(|grant| grant.account_id), + ) + .await { return Ok(Err(err.into())); } diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs index 14a34230..1d44538a 100644 --- a/crates/jmap/src/principal/get.rs +++ b/crates/jmap/src/principal/get.rs @@ -7,6 +7,7 @@ */ use common::{Server, auth::AccessToken}; +use email::known_accounts::KnownAccounts; use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::principal::{Principal, PrincipalProperty, PrincipalType, PrincipalValue}, @@ -52,9 +53,9 @@ impl PrincipalGet for Server { .caused_by(trc::location!())?; // inbuxa: with directory queries off, only the accounts this one already - // has to do with: its own, its groups, and whoever shared with it. + // has to do with, so it can still put a name to who it shares with. if !self.can_read_directory(access_token) { - principal_ids &= RoaringBitmap::from_iter(access_token.all_ids()); + principal_ids &= self.known_accounts(access_token).await?; } let ids = if let Some(ids) = ids { diff --git a/tests/src/imap/acl.rs b/tests/src/imap/acl.rs index 18ca57f4..f4c45b13 100644 --- a/tests/src/imap/acl.rs +++ b/tests/src/imap/acl.rs @@ -7,7 +7,10 @@ */ use super::{AssertResult, ImapConnection, Type, append::assert_append_message}; -use crate::utils::{server::TestServer, smtp::SmtpConnection}; +use crate::{ + jmap::principal::directory_off::set_directory_queries, + utils::{server::TestServer, smtp::SmtpConnection}, +}; use imap_proto::ResponseType; pub async fn test( @@ -456,4 +459,54 @@ pub async fn test( .assert_read(Type::Tagged, ResponseType::Ok) .await .assert_count("Shared Folders", 3); + + // inbuxa: with directory queries off, SETACL can't reach anyone new, and an + // account that exists is refused the same way as one that doesn't + let admin = test.account("admin"); + let stranger = admin + .create_user_account( + "stranger@example.com", + "stranger-secret-1234", + "Stranger", + &[], + vec![], + ) + .await; + // (a session keeps the settings it started with, so each change reconnects) + set_directory_queries(test, false).await; + let mut imap_off = test.account("jdoe@example.com").imap_client().await; + for account in ["stranger@example.com", "nobody@example.com"] { + imap_off + .send(&format!("SETACL \"INBOX\" {account} lr")) + .await; + imap_off + .assert_read(Type::Tagged, ResponseType::No) + .await + .assert_contains("Account does not exist"); + imap_off.send("GETACL \"INBOX\"").await; + imap_off + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count(account, 0); + } + + // Someone already on the mailbox stays usable + set_directory_queries(test, true).await; + let mut imap_on = test.account("jdoe@example.com").imap_client().await; + imap_on + .send("SETACL \"INBOX\" stranger@example.com lr") + .await; + imap_on.assert_read(Type::Tagged, ResponseType::Ok).await; + set_directory_queries(test, false).await; + let mut imap_off = test.account("jdoe@example.com").imap_client().await; + imap_off + .send("SETACL \"INBOX\" stranger@example.com lrs") + .await; + imap_off.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_off + .send("DELETEACL \"INBOX\" stranger@example.com") + .await; + imap_off.assert_read(Type::Tagged, ResponseType::Ok).await; + set_directory_queries(test, true).await; + admin.destroy_account(stranger).await; } diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 57c6405f..ad68786a 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -223,6 +223,13 @@ pub async fn imap_tests() { namespace: "spam-traps".into(), }) .await; + // inbuxa: the ACL tests share with accounts they have nothing else to do with + admin + .registry_create_object(registry::schema::structs::Sharing { + allow_directory_queries: true, + ..Default::default() + }) + .await; admin.reload_settings().await; admin.reload_lookup_stores().await; diff --git a/tests/src/jmap/principal/directory_off.rs b/tests/src/jmap/principal/directory_off.rs index b837828f..4a69448e 100644 --- a/tests/src/jmap/principal/directory_off.rs +++ b/tests/src/jmap/principal/directory_off.rs @@ -4,7 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use crate::utils::{jmap::JmapUtils, server::TestServer}; +use crate::utils::{ + account::Account, + jmap::{JmapResponse, JmapUtils}, + server::TestServer, +}; use registry::schema::{prelude::Property, structs::Sharing}; use serde_json::{Value, json}; @@ -77,6 +81,164 @@ pub async fn test(test: &TestServer) { "{response:?}" ); + // Nobody new can be shared with, and an account that exists is refused the + // same way as one that doesn't, so a grant can't be used to look anyone up + let robert = test.account("robert@example.com"); + let robert_id = robert.id_string(); + let calendar = first_id(john, "Calendar/get").await; + let book = first_id(john, "AddressBook/get").await; + let inbox = first_id(john, "Mailbox/get").await; + for (object, id, rights) in [ + ( + "Calendar", + calendar.as_str(), + json!({ "mayReadFreeBusy": true }), + ), + ("AddressBook", book.as_str(), json!({ "mayRead": true })), + ("Mailbox", inbox.as_str(), json!({ "mayReadItems": true })), + ] { + for grantee in [jane_id, "zzzzzz"] { + let response = share(john, object, id, json!({ grantee: rights })).await; + assert_eq!( + response.not_updated(id)["type"], + "invalidForeignKey", + "{object} {grantee}: {response:?}" + ); + } + } + + // Those already shared with stay usable. Jane and Robert get John's folder + // and Jane his calendar while the setting is on. + set_directory_queries(test, true).await; + let response = john + .jmap_method_call( + "FileNode/set", + json!({ + "accountId": john_id, + "create": { "i0": { + "name": "Directory off", + "shareWith": { + jane_id: { "mayRead": true, "mayAddChildren": true }, + robert_id: { "mayRead": true }, + } + }} + }), + ) + .await; + let folder = response.created(0).id().to_string(); + let response = share( + john, + "Calendar", + &calendar, + json!({ jane_id: { "mayReadFreeBusy": true } }), + ) + .await; + assert!(response.updated(&calendar).is_null(), "{response:?}"); + set_directory_queries(test, false).await; + + // John can name them, though he still can't list anyone or see the rest + let response = john + .jmap_method_call( + "Principal/get", + json!({ + "accountId": john_id, + "ids": [jane_id, robert_id, test.account("bill@example.com").id_string()], + "properties": ["id"] + }), + ) + .await; + let found = ids(&response.method_response()["list"]); + assert!( + found.contains(&jane_id) && found.contains(&robert_id), + "{response:?}" + ); + assert_eq!( + response.method_response()["notFound"], + json!([test.account("bill@example.com").id_string()]), + "{response:?}" + ); + + // He can change what Jane has, and share something else with her + let response = share( + john, + "Calendar", + &calendar, + json!({ jane_id: { "mayReadFreeBusy": true, "mayReadItems": true } }), + ) + .await; + assert!(response.updated(&calendar).is_null(), "{response:?}"); + let response = share( + john, + "AddressBook", + &book, + json!({ jane_id: { "mayRead": true } }), + ) + .await; + assert!(response.updated(&book).is_null(), "{response:?}"); + + // Jane knows John now, and can read his free/busy, but Robert isn't hers + // to see: she can't manage the folder they're both on + let response = jane + .jmap_method_call( + "Principal/get", + json!({ "accountId": jane_id, "ids": [john_id, robert_id], "properties": ["id"] }), + ) + .await; + assert_eq!( + ids(&response.method_response()["list"]), + [john_id], + "{response:?}" + ); + let response = jane + .jmap_method_call( + "Principal/getAvailability", + json!({ + "accountId": jane_id, + "id": john_id, + "utcStart": "2026-01-01T00:00:00Z", + "utcEnd": "2026-01-02T00:00:00Z", + }), + ) + .await; + assert!( + response.method_response()["list"].is_array(), + "{response:?}" + ); + + // Something she makes in the folder inherits Robert, who isn't new to it + let response = jane + .jmap_method_call( + "FileNode/set", + json!({ + "accountId": john_id, + "create": { "i0": { "name": "Inherited", "parentId": folder } } + }), + ) + .await; + response.created(0); + + // Sharing can be taken away, and then Jane can't be named anymore + let response = share(john, "Calendar", &calendar, Value::Null).await; + assert!(response.updated(&calendar).is_null(), "{response:?}"); + let response = share(john, "AddressBook", &book, Value::Null).await; + assert!(response.updated(&book).is_null(), "{response:?}"); + john.jmap_method_call( + "FileNode/set", + json!({ "accountId": john_id, "destroy": [folder], "onDestroyRemoveChildren": true }), + ) + .await; + let response = john + .jmap_method_call( + "Principal/get", + json!({ "accountId": john_id, "ids": [jane_id], "properties": ["id"] }), + ) + .await; + assert_eq!( + response.method_response()["notFound"], + json!([jane_id]), + "{response:?}" + ); + // Whoever can list accounts anyway isn't held back let response = admin .jmap_method_calls(json!([ @@ -111,11 +273,40 @@ pub async fn test(test: &TestServer) { "{response:?}" ); - // Asking for free/busy made John's default calendar - john.destroy_all_calendars().await; + // Asking for free/busy and looking up who's shared with made these + for account in [john, jane] { + account.destroy_all_calendars().await; + account.destroy_all_addressbooks().await; + test.destroy_all_mailboxes(account).await; + } test.assert_is_empty().await; } +async fn first_id(account: &Account, method: &str) -> String { + account + .jmap_method_call( + method, + json!({ "accountId": account.id_string(), "ids": null, "properties": ["id"] }), + ) + .await + .method_response()["list"][0]["id"] + .as_str() + .unwrap_or_else(|| panic!("{method} returned nothing")) + .to_string() +} + +async fn share(account: &Account, object: &str, id: &str, share_with: Value) -> JmapResponse { + account + .jmap_method_call( + &format!("{object}/set"), + json!({ + "accountId": account.id_string(), + "update": { id: { "shareWith": share_with } } + }), + ) + .await +} + fn ids(list: &Value) -> Vec<&str> { list.as_array() .unwrap_or_else(|| panic!("Expected a list: {list:?}")) diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index b6564815..34df806b 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -65,6 +65,12 @@ pub async fn system_tests() { ..Default::default() }) .await + // inbuxa: these share with accounts they have nothing else to do with + .with_object(registry::schema::structs::Sharing { + allow_directory_queries: true, + ..Default::default() + }) + .await .build() .await; diff --git a/tests/src/system/sharing_policy.rs b/tests/src/system/sharing_policy.rs index e12e9b51..9f12b419 100644 --- a/tests/src/system/sharing_policy.rs +++ b/tests/src/system/sharing_policy.rs @@ -99,6 +99,12 @@ pub async fn sharing_policy_tests() { .await .with_default_listeners() .await + // inbuxa: these share with accounts they have nothing else to do with + .with_object(registry::schema::structs::Sharing { + allow_directory_queries: true, + ..Default::default() + }) + .await .build() .await; let admin = test.create_admin_account("admin@example.org").await; diff --git a/tests/src/webdav/principals.rs b/tests/src/webdav/principals.rs index 97c34f1b..02bedbad 100644 --- a/tests/src/webdav/principals.rs +++ b/tests/src/webdav/principals.rs @@ -531,6 +531,20 @@ pub async fn test(test: &TestServer, assisted_discovery: bool) { principal_href("jane").as_str(), principal_href("support").as_str(), ]); + // ACL can't reach anyone new either, whether the account exists or not + for grantee in ["john", "nobody"] { + client + .acl( + &format!( + "{}/jane%40example.com/default/", + DavResourceName::Cal.base_path() + ), + &principal_href(grantee), + ["read"], + ) + .await + .with_status(StatusCode::FORBIDDEN); + } // Whoever can list accounts anyway isn't held back // (not webdav_client(): the administrator has no address) DummyWebDavClient::new( @@ -554,6 +568,11 @@ pub async fn test(test: &TestServer, assisted_discovery: bool) { client .delete_default_containers_by_account("support@example.com") .await; + // Working out who jane knows reads her mail folders and support's + let jane = test.account("jane@example.com"); + test.destroy_all_mailboxes(jane).await; + jane.destroy_all_mailboxes_for_account(test.account("support@example.com").id().document_id()) + .await; test.assert_is_empty().await; }