Show an administrator a share as a share #187

Merged
jcoffey-dev merged 1 commits from fix/admin-calendar-rights into main 2026-10-10 07:53:40 +00:00
26 changed files with 121 additions and 32 deletions

No files matched your search

+19
View File
@@ -608,6 +608,25 @@ impl AccessToken {
self.inner.account_id == account_id || self.inner.member_of.contains(&account_id)
}
/// inbuxa: #186: an account that shared something with this token and
/// isn't its own or a group's it belongs to. Reads there go through the
/// share even for an `Impersonate` holder, so an administrator sees what
/// was shared with them, with the share's rights, like anyone else.
/// Writes still follow `is_member`.
pub fn reads_through_share(&self, account_id: u32) -> bool {
!self.is_member_directly(account_id)
&& self
.inner
.access_to
.iter()
.any(|a| a.account_id == account_id)
}
/// inbuxa: #186: reads see the whole account (see `reads_through_share`).
pub fn reads_as_owner(&self, account_id: u32) -> bool {
self.is_member(account_id) && !self.reads_through_share(account_id)
}
/// inbuxa: MA-D0: in the account only because it is a group this token
/// belongs to. Such a member has the group's mailbox but may not share it
/// on: who is in a group is an administrator's decision, and a share
+4 -2
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::auth::AccessToken;
@@ -27,7 +29,7 @@ impl EffectiveAcl for &[AclGrant] {
fn effective_acl(&self, access_token: &AccessToken) -> Bitmap<Acl> {
let mut acl = Bitmap::<Acl>::new();
for item in self.iter() {
if access_token.is_member(item.account_id) {
if access_token.is_member_directly(item.account_id) {
acl.union(&item.grants);
}
}
@@ -40,7 +42,7 @@ impl EffectiveAcl for ArchivedVec<ArchivedAclGrant> {
fn effective_acl(&self, access_token: &AccessToken) -> Bitmap<Acl> {
let mut acl = Bitmap::<Acl>::new();
for item in self.iter() {
if access_token.is_member(item.account_id.into()) {
if access_token.is_member_directly(item.account_id.into()) {
acl.union_raw(item.grants.bitmap);
}
}
+5 -3
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::{DavResources, auth::AccessToken};
@@ -22,7 +24,7 @@ impl DavResources {
for resource in &self.resources {
if let Some(acls) = resource.acls() {
for acl in acls {
if access_token.is_member(acl.account_id) {
if access_token.is_member_directly(acl.account_id) {
let mut grants = acl.grants;
grants.intersection(&check_acls);
if grants == check_acls || (match_any && !grants.is_empty()) {
@@ -102,7 +104,7 @@ impl DavResources {
&& let Some(acls) = resource.acls()
{
for acl in acls {
if access_token.is_member(acl.account_id) {
if access_token.is_member_directly(acl.account_id) {
let mut grants = acl.grants;
grants.intersection(&check_acls);
return !grants.is_empty();
@@ -123,7 +125,7 @@ impl DavResources {
&& let Some(acls) = resource.acls()
{
for acl in acls {
if access_token.is_member(acl.account_id) {
if access_token.is_member_directly(acl.account_id) {
account_acls.union(&acl.grants);
}
}
+2 -2
View File
@@ -60,7 +60,7 @@ impl AddressBookGet for Server {
SyncCollection::AddressBook,
)
.await?;
let address_book_ids = if access_token.is_member(account_id) {
let address_book_ids = if access_token.reads_as_owner(account_id) {
cache.document_ids(true).collect::<RoaringBitmap>()
} else {
cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true)
@@ -177,7 +177,7 @@ impl AddressBookGet for Server {
AddressBookProperty::MyRights => {
result.insert_unchecked(
AddressBookProperty::MyRights,
if access_token.is_shared(account_id) {
if access_token.reads_through_share(account_id) {
JmapRights::rights::<addressbook::AddressBook>(
address_book.acls.effective_acl(access_token),
)
+1 -1
View File
@@ -116,7 +116,7 @@ impl SessionHandler for Server {
// Add secondary accounts
for &account_id in access_token.secondary_ids() {
let is_owner = access_token.is_member(account_id);
let is_owner = access_token.reads_as_owner(account_id);
let Some(account) = self
.try_account(account_id)
.await
+1 -1
View File
@@ -116,7 +116,7 @@ impl BlobDownload for Server {
collection,
document_id,
} => {
if access_token.is_member(*account_id) {
if access_token.reads_as_owner(*account_id) {
// inbuxa: AU-1.6: another account's blob
self.audit_foreign_access(access_token, *account_id, true)
.await;
+2 -2
View File
@@ -73,7 +73,7 @@ impl CalendarGet for Server {
SyncCollection::Calendar,
)
.await?;
let is_owner = access_token.is_member(account_id);
let is_owner = access_token.reads_as_owner(account_id);
let calendar_ids = if is_owner {
cache.document_ids(true).collect::<RoaringBitmap>()
} else {
@@ -250,7 +250,7 @@ impl CalendarGet for Server {
CalendarProperty::MyRights => {
result.insert_unchecked(
CalendarProperty::MyRights,
if access_token.is_shared(account_id) {
if access_token.reads_through_share(account_id) {
JmapRights::rights::<calendar::Calendar>(
calendar.acls.effective_acl(access_token),
)
+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::{
@@ -90,7 +92,7 @@ impl JmapCalendarEventCopy for Server {
)
.await
.caused_by(trc::location!())?;
let from_calendar_event_ids = if access_token.is_member(from_account_id) {
let from_calendar_event_ids = if access_token.reads_as_owner(from_account_id) {
from_cache.document_ids(false).collect::<RoaringBitmap>()
} else {
from_cache.shared_items(access_token, [Acl::ReadItems], true)
+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::{calendar_event::CalendarSyntheticId, changes::state::JmapCacheState};
@@ -71,7 +73,7 @@ impl CalendarEventGet for Server {
SyncCollection::Calendar,
)
.await?;
let calendar_event_ids = if access_token.is_member(account_id) {
let calendar_event_ids = if access_token.reads_as_owner(account_id) {
cache.document_ids(false).collect::<RoaringBitmap>()
} else {
cache.shared_items(access_token, [Acl::ReadItems], true)
+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::query::QueryResponseBuilder, changes::state::JmapCacheState};
@@ -248,7 +250,7 @@ impl CalendarEventQuery for Server {
.with_filters(filters)
.with_comparators(comparators)
.with_account_id(account_id)
.with_mask(if access_token.is_shared(account_id) {
.with_mask(if access_token.reads_through_share(account_id) {
cache.shared_items(access_token, [Acl::ReadItems], true)
} else {
cache.document_ids(false).collect()
+1 -1
View File
@@ -134,7 +134,7 @@ impl ChangesLookup for Server {
};
let account_id = request.account_id.document_id();
let allowed_ids: Option<RoaringBitmap> = if access_token.is_member(account_id) {
let allowed_ids: Option<RoaringBitmap> = if access_token.reads_as_owner(account_id) {
None
} else {
Some(match object {
+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::{changes::state::JmapCacheState, contact::set::ContactCardSet};
@@ -87,7 +89,7 @@ impl JmapContactCardCopy for Server {
)
.await
.caused_by(trc::location!())?;
let from_contact_ids = if access_token.is_member(from_account_id) {
let from_contact_ids = if access_token.reads_as_owner(from_account_id) {
from_cache.document_ids(false).collect::<RoaringBitmap>()
} else {
from_cache.shared_items(access_token, [Acl::ReadItems], true)
+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::changes::state::JmapCacheState;
@@ -52,7 +54,7 @@ impl ContactCardGet for Server {
SyncCollection::AddressBook,
)
.await?;
let contact_ids = if access_token.is_member(account_id) {
let contact_ids = if access_token.reads_as_owner(account_id) {
cache.document_ids(false).collect::<RoaringBitmap>()
} else {
cache.shared_items(access_token, [Acl::ReadItems], true)
+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::query::QueryResponseBuilder, changes::state::JmapCacheState};
@@ -313,7 +315,7 @@ impl ContactCardQuery for Server {
.with_filters(filters)
.with_comparators(comparators)
.with_account_id(account_id)
.with_mask(if access_token.is_shared(account_id) {
.with_mask(if access_token.reads_through_share(account_id) {
cache.shared_items(access_token, [Acl::ReadItems], true)
} else {
cache.document_ids(false).collect()
+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::{
@@ -79,7 +81,7 @@ impl JmapEmailCopy for Server {
.get_cached_messages(from_account_id)
.await
.caused_by(trc::location!())?;
let from_message_ids = if access_token.is_member(from_account_id) {
let from_message_ids = if access_token.reads_as_owner(from_account_id) {
from_cache.email_document_ids()
} else {
from_cache.shared_messages(access_token, Acl::ReadItems)
+1 -1
View File
@@ -112,7 +112,7 @@ impl EmailGet for Server {
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
let message_ids = if access_token.is_member(account_id) {
let message_ids = if access_token.reads_as_owner(account_id) {
cache.email_document_ids()
} else {
cache.shared_messages(access_token, Acl::ReadItems)
+1 -1
View File
@@ -356,7 +356,7 @@ impl EmailQuery for Server {
.with_filters(filters)
.with_comparators(comparators)
.with_account_id(account_id)
.with_mask(if access_token.is_shared(account_id) {
.with_mask(if access_token.reads_through_share(account_id) {
cached_messages.shared_messages(access_token, Acl::ReadItems)
} else {
cached_messages
+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 common::{Server, auth::AccessToken};
@@ -98,7 +100,7 @@ impl EmailSearchSnippet for Server {
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
let document_ids = if access_token.is_member(account_id) {
let document_ids = if access_token.reads_as_owner(account_id) {
cached_messages.email_document_ids()
} else {
cached_messages.shared_messages(access_token, Acl::ReadItems)
+1 -1
View File
@@ -98,7 +98,7 @@ impl FileNodeCopy for Server {
)
.await
.caused_by(trc::location!())?;
let from_node_ids = if access_token.is_member(from_account_id) {
let from_node_ids = if access_token.reads_as_owner(from_account_id) {
from_cache
.resources
.iter()
+2 -2
View File
@@ -71,7 +71,7 @@ impl FileNodeGet for Server {
)
.await?;
// TODO: draft-14 section 5 case 2 - ancestors of shared nodes should be discoverable with mayRead=false
let file_node_ids = if access_token.is_member(account_id) {
let file_node_ids = if access_token.reads_as_owner(account_id) {
cache
.resources
.iter()
@@ -169,7 +169,7 @@ impl FileNodeGet for Server {
FileNodeProperty::MyRights => {
result.insert_unchecked(
FileNodeProperty::MyRights,
if access_token.is_shared(account_id) {
if access_token.reads_through_share(account_id) {
JmapRights::rights::<file_node::FileNode>(
file_node.acls.effective_acl(access_token),
)
+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::query::QueryResponseBuilder, changes::state::JmapCacheState};
@@ -193,7 +195,7 @@ impl FileNodeQuery for Server {
let results = SearchQuery::new(SearchIndex::InMemory)
.with_filters(filters)
.with_mask(if access_token.is_shared(account_id) {
.with_mask(if access_token.reads_through_share(account_id) {
cache.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true)
} else {
cache.resources.iter().map(|r| r.document_id).collect()
+2 -2
View File
@@ -51,7 +51,7 @@ impl MailboxGet for Server {
let account_id = request.account_id.document_id();
let personal_id = access_token.personal_id(account_id, Collection::Mailbox);
let cache = self.get_cached_messages(account_id).await?;
let shared_ids = if access_token.is_shared(account_id) {
let shared_ids = if access_token.reads_through_share(account_id) {
cache.shared_mailboxes(access_token, Acl::Read).into()
} else {
None
@@ -137,7 +137,7 @@ impl MailboxGet for Server {
.into(),
),
MailboxProperty::MyRights => {
if access_token.is_shared(account_id) {
if access_token.reads_through_share(account_id) {
JmapRights::rights::<Mailbox>(
cached_mailbox.acls.as_slice().effective_acl(access_token),
)
+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::query::QueryResponseBuilder, changes::state::JmapCacheState};
@@ -229,7 +231,7 @@ impl MailboxQuery for Server {
let mut results = SearchQuery::new(SearchIndex::InMemory)
.with_filters(filters)
.with_comparators(comparators)
.with_mask(if access_token.is_shared(account_id) {
.with_mask(if access_token.reads_through_share(account_id) {
mailboxes.shared_mailboxes(access_token, Acl::Read)
} else {
mailboxes
+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::{calendar::Availability, calendar_event::CalendarSyntheticId};
@@ -118,7 +120,7 @@ impl PrincipalGetAvailability for Server {
// Obtain shared ids
let is_account_owner = principal_id == account_id;
let shared_ids = if !access_token.is_member(account_id) {
let shared_ids = if !access_token.reads_as_owner(account_id) {
// Condition: The user has the "mayReadFreeBusy" permission for the calendar.
let shared_ids = resources.shared_items(
access_token,
+1 -1
View File
@@ -44,7 +44,7 @@ impl ThreadGet for Server {
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
let shared_ids = if access_token.is_shared(account_id) {
let shared_ids = if access_token.reads_through_share(account_id) {
Some(cache.shared_messages(access_token, Acl::ReadItems))
} else {
None
+45 -1
View File
@@ -253,7 +253,7 @@ pub async fn test(test: &mut TestServer) {
&mail,
json!([["Mailbox/set", {
"accountId": sharer.id_string(),
"update": {inbox: {format!("shareWith/{admin_id}"): {"mayReadItems": true}}}
"update": {inbox.clone(): {format!("shareWith/{admin_id}"): {"mayReadItems": true}}}
}, "0"]]),
)
.await;
@@ -281,6 +281,50 @@ pub async fn test(test: &mut TestServer) {
"test 3b"
);
// Test 3c (#186): in an account that shared with them, an administrator
// reads what was shared, with the share's rights: not every folder, and
// not folders shared with someone else.
let sent = mailboxes
.0
.pointer("/methodResponses/0/1/list")
.and_then(|list| list.as_array())
.and_then(|list| list.iter().find(|m| m["role"] == "sent"))
.and_then(|m| m["id"].as_str())
.unwrap_or_else(|| panic!("test 3c: no Sent in {}", mailboxes.0))
.to_string();
let shared = sharer
.jmap_request(
&mail,
json!([["Mailbox/set", {
"accountId": sharer.id_string(),
"update": {sent: {format!("shareWith/{}", user.id_string()): {"mayReadItems": true}}}
}, "0"]]),
)
.await;
assert!(
shared
.0
.pointer("/methodResponses/0/1/updated")
.is_some_and(|updated| !updated.is_null()),
"test 3c: share: {}",
shared.0
);
let response = admin
.jmap_request(
&mail,
json!([["Mailbox/get", {"accountId": sharer.id_string(), "ids": null, "properties": ["id", "myRights"]}, "0"]]),
)
.await;
let list = response
.0
.pointer("/methodResponses/0/1/list")
.and_then(|list| list.as_array())
.unwrap_or_else(|| panic!("test 3c: {}", response.0));
assert_eq!(list.len(), 1, "test 3c: {}", response.0);
assert_eq!(list[0]["id"], inbox.as_str(), "test 3c");
assert_eq!(list[0]["myRights"]["mayReadItems"], true, "test 3c");
assert_eq!(list[0]["myRights"]["mayDelete"], false, "test 3c");
// AU-9: a plain user can't read the audit log
let plain = Account::new(
"[email protected]",