Keep sharing to known accounts when directory queries are off #283
No files matched your search
Generated
+1
@@ -1894,6 +1894,7 @@ dependencies = [
|
||||
"compact_str",
|
||||
"dav-proto",
|
||||
"directory",
|
||||
"email",
|
||||
"groupware",
|
||||
"hashify",
|
||||
"http_proto",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:?}"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user