Don't let a group's members share its calendars, address books or files #147
No files matched your search
@@ -133,6 +133,10 @@ impl DavAclHandler for Server {
|
||||
{
|
||||
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
||||
}
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||
if access_token.is_group_member_only(account_id) {
|
||||
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
// Validate ACEs
|
||||
let grants = self
|
||||
@@ -565,7 +569,13 @@ impl Privileges for AccessToken {
|
||||
grants: &ArchivedVec<ArchivedAclGrant>,
|
||||
is_calendar: bool,
|
||||
) -> Vec<Privilege> {
|
||||
if self.is_member(account_id) {
|
||||
if self.is_group_member_only(account_id) {
|
||||
// inbuxa: MA-D0: everything but sharing it on.
|
||||
Privilege::all(is_calendar)
|
||||
.into_iter()
|
||||
.filter(|privilege| !matches!(privilege, Privilege::All | Privilege::WriteAcl))
|
||||
.collect()
|
||||
} else if self.is_member(account_id) {
|
||||
Privilege::all(is_calendar)
|
||||
} else {
|
||||
current_user_privilege_set(grants.effective_acl(self))
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||
@@ -180,7 +182,7 @@ impl AddressBookGet for Server {
|
||||
address_book.acls.effective_acl(access_token),
|
||||
)
|
||||
} else {
|
||||
JmapRights::all_rights::<addressbook::AddressBook>()
|
||||
JmapRights::owner_rights::<addressbook::AddressBook>(access_token, account_id)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,14 @@ impl AddressBookSet for Server {
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||
if !address_book.acls.is_empty() && access_token.is_group_member_only(account_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
// Validate ACLs
|
||||
if !address_book.acls.is_empty() {
|
||||
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
|
||||
@@ -203,6 +211,14 @@ impl AddressBookSet for Server {
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
if has_acl_changes {
|
||||
if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
|
||||
response.not_updated.append(id, err.into());
|
||||
|
||||
@@ -187,6 +187,21 @@ impl JmapRights {
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
/// inbuxa: MA-D0: an owner's rights, which for a group's member are
|
||||
/// everything but sharing it on.
|
||||
pub fn owner_rights<T: JmapSharedObject>(
|
||||
access_token: &AccessToken,
|
||||
account_id: u32,
|
||||
) -> Value<'static, T::Property, T::Element> {
|
||||
if access_token.is_group_member_only(account_id) {
|
||||
let mut acl = Bitmap::<Acl>::all();
|
||||
acl.remove(Acl::Share);
|
||||
Self::rights::<T>(acl)
|
||||
} else {
|
||||
Self::all_rights::<T>()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rights<T: JmapSharedObject>(
|
||||
acls: Bitmap<Acl>,
|
||||
) -> Value<'static, T::Property, T::Element> {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
|
||||
@@ -253,7 +255,7 @@ impl CalendarGet for Server {
|
||||
calendar.acls.effective_acl(access_token),
|
||||
)
|
||||
} else {
|
||||
JmapRights::all_rights::<calendar::Calendar>()
|
||||
JmapRights::owner_rights::<calendar::Calendar>(access_token, account_id)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,14 @@ impl CalendarSet for Server {
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||
if !calendar.acls.is_empty() && access_token.is_group_member_only(account_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
// Validate ACLs
|
||||
if !calendar.acls.is_empty() {
|
||||
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
|
||||
@@ -207,6 +215,14 @@ impl CalendarSet for Server {
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
if has_acl_changes {
|
||||
if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
|
||||
response.not_updated.append(id, err.into());
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||
@@ -172,7 +174,7 @@ impl FileNodeGet for Server {
|
||||
file_node.acls.effective_acl(access_token),
|
||||
)
|
||||
} else {
|
||||
JmapRights::all_rights::<file_node::FileNode>()
|
||||
JmapRights::owner_rights::<file_node::FileNode>(access_token, account_id)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,6 +250,16 @@ impl FileNodeSet for Server {
|
||||
},
|
||||
};
|
||||
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on,
|
||||
// at the top of its files as anywhere else
|
||||
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
// Inherit ACLs from parent
|
||||
if file_node.parent_id > 0 {
|
||||
let parent_id = file_node.parent_id - 1;
|
||||
@@ -509,6 +519,14 @@ impl FileNodeSet for Server {
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
if has_acl_changes {
|
||||
if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
|
||||
response.not_updated.append(id, err.into());
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{AssertResult, ImapConnection, Type, append::assert_append_message};
|
||||
@@ -69,6 +71,15 @@ pub async fn test(
|
||||
.await;
|
||||
imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await;
|
||||
|
||||
// inbuxa: MA-D0: but she can't share the group's mailbox on
|
||||
imap_jane
|
||||
.send("SETACL \"Shared Folders/[email protected]/INBOX\" [email protected] lr")
|
||||
.await;
|
||||
imap_jane
|
||||
.assert_read(Type::Tagged, ResponseType::No)
|
||||
.await
|
||||
.assert_contains("NOPERM");
|
||||
|
||||
// John should have no shared folders
|
||||
imap_john.send("LIST \"\" \"*\"").await;
|
||||
imap_john
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! MA-D0 (specs/multi-account.md): a group's members have its calendars,
|
||||
//! address books and files, but can't share them on. Who is in a group is
|
||||
//! an administrator's decision. Mailboxes are checked in `mail::acl`.
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||
use jmap_proto::request::method::MethodObject;
|
||||
use registry::schema::prelude::ObjectType;
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running group sharing tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let sales = test.account("[email protected]");
|
||||
let bill = test.account("[email protected]");
|
||||
let robert = test.account("[email protected]");
|
||||
let robert_id = robert.id_string().to_string();
|
||||
|
||||
// Bill joins the group; Robert stays outside it
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
bill.id(),
|
||||
json!({"memberGroupIds": {sales.id_string(): true}}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Each kind's own name for "may read"
|
||||
for (object, read) in [
|
||||
(MethodObject::Calendar, "mayReadItems"),
|
||||
(MethodObject::AddressBook, "mayRead"),
|
||||
(MethodObject::FileNode, "mayRead"),
|
||||
] {
|
||||
// Made with a share: refused
|
||||
let response = bill
|
||||
.jmap_create_account(
|
||||
sales,
|
||||
object,
|
||||
[json!({
|
||||
"name": "Shared on",
|
||||
"shareWith": {&robert_id: {read: true}}
|
||||
})],
|
||||
Vec::<(&str, &str)>::new(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.pointer("/methodResponses/0/1/notCreated/i0/type"),
|
||||
Some(&json!("forbidden")),
|
||||
"MA-D0: {object} created with a share: {:?}",
|
||||
response.pointer("/methodResponses/0")
|
||||
);
|
||||
|
||||
// Made without one: fine, and it says it can't be shared
|
||||
let id = bill
|
||||
.jmap_create_account(
|
||||
sales,
|
||||
object,
|
||||
[json!({"name": "The group's"})],
|
||||
Vec::<(&str, &str)>::new(),
|
||||
)
|
||||
.await
|
||||
.created(0)
|
||||
.id()
|
||||
.to_string();
|
||||
let rights = bill
|
||||
.jmap_get_account(sales, object, ["myRights"], [id.as_str()])
|
||||
.await
|
||||
.list()[0]["myRights"]
|
||||
.clone();
|
||||
assert_eq!(rights["mayShare"], false, "MA-D0: {object} myRights {rights}");
|
||||
assert_eq!(rights["mayDelete"], true, "MA-D0: {object} myRights {rights}");
|
||||
|
||||
// Shared afterwards: refused
|
||||
let response = bill
|
||||
.jmap_update_account(
|
||||
sales,
|
||||
object,
|
||||
[(
|
||||
&id,
|
||||
json!({format!("shareWith/{robert_id}"): {read: true}}),
|
||||
)],
|
||||
Vec::<(&str, &str)>::new(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.pointer(&format!("/methodResponses/0/1/notUpdated/{id}/type")),
|
||||
Some(&json!("forbidden")),
|
||||
"MA-D0: {object} shared on: {:?}",
|
||||
response.pointer("/methodResponses/0")
|
||||
);
|
||||
|
||||
// Robert still has nothing
|
||||
assert_eq!(
|
||||
robert
|
||||
.jmap_get_account(sales, object, Vec::<&str>::new(), [id.as_str()])
|
||||
.await
|
||||
.method_response()
|
||||
.typ(),
|
||||
"forbidden",
|
||||
"MA-D0: {object} reached from outside"
|
||||
);
|
||||
|
||||
bill.jmap_destroy_account(sales, object, [id.as_str()], Vec::<(&str, &str)>::new())
|
||||
.await;
|
||||
}
|
||||
|
||||
// Reaching the group's calendars and address books made its defaults
|
||||
let sales_id = sales.id_string();
|
||||
bill.jmap_method_calls(json!([
|
||||
["Calendar/get", {"accountId": sales_id, "ids": (), "properties": ["id"]}, "c"],
|
||||
["Calendar/set", {"accountId": sales_id, "onDestroyRemoveEvents": true,
|
||||
"#destroy": {"resultOf": "c", "name": "Calendar/get", "path": "/list/*/id"}}, "cd"],
|
||||
["AddressBook/get", {"accountId": sales_id, "ids": (), "properties": ["id"]}, "a"],
|
||||
["AddressBook/set", {"accountId": sales_id, "onDestroyRemoveContents": true,
|
||||
"#destroy": {"resultOf": "a", "name": "AddressBook/get", "path": "/list/*/id"}}, "ad"]
|
||||
]))
|
||||
.await;
|
||||
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
bill.id(),
|
||||
json!({"memberGroupIds": {sales.id_string(): false}}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
@@ -22,6 +24,7 @@ pub mod compliance;
|
||||
pub mod contacts;
|
||||
pub mod core;
|
||||
pub mod files;
|
||||
pub mod group_share;
|
||||
pub mod mail;
|
||||
pub mod principal;
|
||||
|
||||
@@ -219,6 +222,7 @@ pub async fn jmap_tests() {
|
||||
|
||||
calendar::identity::test(&test).await;
|
||||
calendar::acl::test(&test).await;
|
||||
group_share::test(&test).await;
|
||||
|
||||
principal::get::test(&test).await;
|
||||
principal::availability::test(&test).await;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
||||
@@ -407,6 +409,40 @@ pub async fn test(test: &TestServer) {
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// inbuxa: MA-D0: Jane, a member of the Support group, can make a folder in
|
||||
// the group's account but can't share it on
|
||||
let member_client = test.account("[email protected]").webdav_client();
|
||||
let john_principal = format!(
|
||||
"{}/john%40example.com/",
|
||||
DavResourceName::Principal.base_path()
|
||||
);
|
||||
for resource_type in [
|
||||
DavResourceName::File,
|
||||
DavResourceName::Cal,
|
||||
DavResourceName::Card,
|
||||
] {
|
||||
let group_folder = format!(
|
||||
"{}/support%40example.com/group-folder/",
|
||||
resource_type.base_path()
|
||||
);
|
||||
member_client
|
||||
.request("MKCOL", &group_folder, "")
|
||||
.await
|
||||
.with_status(StatusCode::CREATED);
|
||||
member_client
|
||||
.acl(&group_folder, john_principal.as_str(), ["read"])
|
||||
.await
|
||||
.with_status(StatusCode::FORBIDDEN);
|
||||
member_client
|
||||
.request("DELETE", &group_folder, "")
|
||||
.await
|
||||
.with_status(StatusCode::NO_CONTENT);
|
||||
}
|
||||
// Reaching the group's calendars and address books made its defaults
|
||||
member_client
|
||||
.delete_default_containers_by_account("[email protected]")
|
||||
.await;
|
||||
|
||||
sharee_client.delete_default_containers().await;
|
||||
owner_client.delete_default_containers().await;
|
||||
test.assert_is_empty().await;
|
||||
|
||||
Reference in new issue
Block a user