Keep sharing to known accounts when directory queries are off #283

Merged
jcoffey-dev merged 1 commits from fix/directory-off-acl-route into main 2026-11-02 01:31:12 +00:00
20 changed files with 535 additions and 15 deletions

No files matched your search

Generated
+1
View File
@@ -1894,6 +1894,7 @@ dependencies = [
"compact_str",
"dav-proto",
"directory",
"email",
"groupware",
"hashify",
"http_proto",
+1
View File
@@ -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" }
+23
View File
@@ -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)
+2 -1
View File
@@ -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)?;
+80
View File
@@ -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<Output = trc::Result<RoaringBitmap>> + Send;
}
impl KnownAccounts for Server {
async fn known_accounts(&self, access_token: &AccessToken) -> trc::Result<RoaringBitmap> {
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::<RoaringBitmap>();
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::<RoaringBitmap>();
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)
}
}
+1
View File
@@ -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;
+24
View File
@@ -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<T: SessionStream> Session<T> {
.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
+22 -2
View File
@@ -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;
}
+26
View File
@@ -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<Item = u32> + Send,
) -> impl Future<Output = Result<(), ShareValidationError>> + 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<Item = u32> + Send,
) -> Result<(), ShareValidationError> {
let previous = previous.collect::<RoaringBitmap>();
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
+17 -2
View File
@@ -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;
}
+14 -1
View File
@@ -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;
}
+27 -2
View File
@@ -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;
}
+8 -1
View File
@@ -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()));
}
+3 -2
View File
@@ -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 {
+54 -1
View File
@@ -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(
"[email protected]",
"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("[email protected]").imap_client().await;
for account in ["[email protected]", "[email protected]"] {
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("[email protected]").imap_client().await;
imap_on
.send("SETACL \"INBOX\" [email protected] lr")
.await;
imap_on.assert_read(Type::Tagged, ResponseType::Ok).await;
set_directory_queries(test, false).await;
let mut imap_off = test.account("[email protected]").imap_client().await;
imap_off
.send("SETACL \"INBOX\" [email protected] lrs")
.await;
imap_off.assert_read(Type::Tagged, ResponseType::Ok).await;
imap_off
.send("DELETEACL \"INBOX\" [email protected]")
.await;
imap_off.assert_read(Type::Tagged, ResponseType::Ok).await;
set_directory_queries(test, true).await;
admin.destroy_account(stranger).await;
}
+7
View File
@@ -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;
+194 -3
View File
@@ -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("[email protected]");
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("[email protected]").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("[email protected]").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:?}"))
+6
View File
@@ -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;
+6
View File
@@ -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("[email protected]").await;
+19
View File
@@ -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("[email protected]")
.await;
// Working out who jane knows reads her mail folders and support's
let jane = test.account("[email protected]");
test.destroy_all_mailboxes(jane).await;
jane.destroy_all_mailboxes_for_account(test.account("[email protected]").id().document_id())
.await;
test.assert_is_empty().await;
}