Don't let a group's members share its calendars, address books or files
github/ci (branch) GitHub Actions
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Canceled after 25m26s

#146 stopped a group's members sharing its mailboxes on. The same
shortcut lets them through everywhere else a group owns things: a
member counts as the account's owner, so Calendar/set, AddressBook/set
and FileNode/set skip the share check, and so does the WebDAV ACL
method. Who has what a group owns is decided by who is in the group.

For a member through a group only (is_group_member_only):

- Calendar/set, AddressBook/set and FileNode/set refuse a shareWith
  change as forbidden, on create and update; for files at the top of
  the account too, not only inside a folder;
- the DAV ACL method answers 403 on the group's calendars, address
  books and files;
- myRights reports mayShare false (JmapRights::owner_rights), and the
  DAV current-user-privilege-set leaves out all and write-acl.

Reading who something is shared with is unchanged, as in JMAP.

Tests: a new jmap::group_share module has a member create with a
share, create without one (and check myRights), share afterwards, and
an outsider reach each kind; the WebDAV ACL test has a member try the
ACL method on the group's folders; the IMAP ACL test now checks #146's
SETACL refusal, which had no test of its own. jmap_tests, webdav_tests
and imap_tests pass (RocksDB). specs/multi-account.md MA-D0.
This commit is contained in:
jcoffey-dev committed 2026-10-05 15:03:15 -07:00
1 parent 5f6548bfdd
commit 58d2804278
12 files changed
+267 -4

No files matched your search

+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;
}