Merge pull request 'Don't let a group's members share its calendars, address books or files' (#147) from fix/group-collections-no-onward-share into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 12m49s

This commit was merged in pull request #147.
This commit is contained in:
jcoffey-dev committed 2026-10-05 23:16:39 +00:00
commit f791c78d17
12 files changed
+267 -4

No files matched your search

+11 -1
View File
@@ -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))
+3 -1
View File
@@ -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)
},
);
}
+16
View File
@@ -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());
+15
View File
@@ -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> {
+3 -1
View File
@@ -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)
},
);
}
+16
View File
@@ -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());
+3 -1
View File
@@ -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)
},
);
}
+18
View File
@@ -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());
+11
View File
@@ -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
+131
View File
@@ -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;
}
+4
View File
@@ -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;
+36
View File
@@ -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;