Files
inbuxa-server/crates/jmap/src/api/acl.rs
T
jcoffey-dev 58d2804278
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
Don't let a group's members share its calendars, address books or files
#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.
2026-10-05 15:03:15 -07:00

321 lines
10 KiB
Rust

/*
* 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, sharing::EffectiveAcl};
use jmap_proto::{
error::set::{SetError, SetErrorType},
object::{JmapRight, JmapSharedObject},
};
use jmap_tools::{JsonPointerIter, Key, Map, Property, Value};
use registry::{schema::prelude::ObjectType, types::id::ObjectId};
use store::{registry::RegistryQuery, roaring::RoaringBitmap};
use types::{
acl::{Acl, AclGrant},
id::Id,
};
use utils::map::bitmap::Bitmap;
pub struct JmapRights;
impl JmapRights {
pub fn acl_set<T: JmapSharedObject>(
value: Value<'_, T::Property, T::Element>,
) -> Result<Vec<AclGrant>, SetError<T::Property>>
where
Id: TryFrom<T::Property>,
T::Right: TryFrom<T::Property>,
{
let mut grants = Vec::new();
for (key, value) in value.into_expanded_object() {
let account_id = key
.try_into_property()
.and_then(|p| Id::try_from(p).ok())
.ok_or_else(|| {
SetError::invalid_properties()
.with_property(T::SHARE_WITH_PROPERTY)
.with_description("Invalid account id.")
})?
.document_id();
if !grants
.iter()
.any(|item: &AclGrant| item.account_id == account_id)
{
let acls = Self::map_acls::<T>(value)?;
if !acls.is_empty() {
grants.push(AclGrant {
account_id,
grants: acls,
});
}
}
}
Ok(grants)
}
pub fn acl_patch<T: JmapSharedObject>(
mut grants: Vec<AclGrant>,
mut path: JsonPointerIter<'_, T::Property>,
value: Value<'_, T::Property, T::Element>,
) -> Result<Vec<AclGrant>, SetError<T::Property>>
where
Id: TryFrom<T::Property>,
T::Right: TryFrom<T::Property>,
{
let account_id = path
.next()
.and_then(|item| item.as_property_key())
.cloned()
.and_then(|p| Id::try_from(p).ok())
.ok_or_else(|| {
SetError::invalid_properties()
.with_property(T::SHARE_WITH_PROPERTY)
.with_description("Invalid account id.")
})?
.document_id();
if let Some(right) = path.next() {
if path.next().is_some() {
return Err(SetError::invalid_properties()
.with_property(T::SHARE_WITH_PROPERTY)
.with_description("Invalid path for ACL patch."));
}
let is_set = match value {
Value::Bool(is_set) => is_set,
Value::Null => false,
_ => {
return Err(SetError::invalid_properties()
.with_property(T::SHARE_WITH_PROPERTY)
.with_description("Invalid ACL value."));
}
};
let acl = right
.as_property_key()
.cloned()
.and_then(|p| T::Right::try_from(p).ok())
.ok_or_else(|| {
SetError::invalid_properties()
.with_property(T::SHARE_WITH_PROPERTY)
.with_description(format!(
"Invalid permission {:?}.",
right.to_cow().unwrap_or_default()
))
})?
.to_acl()
.iter()
.copied();
if let Some(acl_item) = grants.iter_mut().find(|item| item.account_id == account_id) {
if is_set {
acl_item.grants.insert_many(acl);
} else {
acl_item.grants.remove_many(acl);
if acl_item.grants.is_empty() {
grants.retain(|item| item.account_id != account_id);
}
}
} else if is_set {
grants.push(AclGrant {
account_id,
grants: Bitmap::from_iter(acl),
});
}
} else {
let acls = Self::map_acls::<T>(value)?;
if !acls.is_empty() {
if let Some(acl_item) = grants.iter_mut().find(|item| item.account_id == account_id)
{
acl_item.grants = acls;
} else {
grants.push(AclGrant {
account_id,
grants: acls,
});
}
} else {
grants.retain(|item| item.account_id != account_id);
}
}
Ok(grants)
}
fn map_acls<T: JmapSharedObject>(
value: Value<'_, T::Property, T::Element>,
) -> Result<Bitmap<Acl>, SetError<T::Property>>
where
Id: TryFrom<T::Property>,
T::Right: TryFrom<T::Property>,
{
let mut acls = Bitmap::new();
for key in value.into_expanded_boolean_set() {
acls.insert_many(
key.as_property()
.and_then(|p| T::Right::try_from(p.clone()).ok())
.ok_or_else(|| {
SetError::invalid_properties()
.with_property(T::SHARE_WITH_PROPERTY)
.with_description(format!("Invalid permission {:?}.", key.to_string()))
})?
.to_acl()
.iter()
.copied(),
);
}
Ok(acls)
}
pub fn all_rights<T: JmapSharedObject>() -> Value<'static, T::Property, T::Element> {
let rights = T::Right::all_rights();
let mut obj = Map::with_capacity(rights.len());
for right in rights {
obj.insert_unchecked(Key::Property((*right).into()), Value::Bool(true));
}
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> {
let mut obj = Map::with_capacity(3);
for right in T::Right::all_rights() {
obj.insert_unchecked(
Key::Property((*right).into()),
Value::Bool(right.to_acl().iter().all(|acl| acls.contains(*acl))),
);
}
Value::Object(obj)
}
pub fn share_with<T: JmapSharedObject>(
account_id: u32,
access_token: &AccessToken,
grants: &[AclGrant],
) -> Value<'static, T::Property, T::Element>
where
T::Property: From<Id>,
{
if access_token.is_member(account_id)
|| grants.effective_acl(access_token).contains(Acl::Share)
{
let mut share_with = Map::with_capacity(grants.len());
for grant in grants {
share_with.insert_unchecked(
Key::Property(Id::from(grant.account_id).into()),
Self::rights::<T>(grant.grants),
);
}
Value::Object(share_with)
} else {
Value::Null
}
}
}
pub trait JmapAcl {
fn acl_validate(
&self,
account_id: u32,
grants: &[AclGrant],
) -> impl Future<Output = Result<(), ShareValidationError>> + Send;
}
pub enum ShareValidationError {
MaxSharesExceeded(usize),
InvalidAccountId(Id),
}
impl JmapAcl for Server {
async fn acl_validate(
&self,
account_id: u32,
grants: &[AclGrant],
) -> Result<(), ShareValidationError> {
if grants.len() > self.core.groupware.max_shares_per_item {
return Err(ShareValidationError::MaxSharesExceeded(
self.core.groupware.max_shares_per_item,
));
}
let principal_ids = self
.registry()
.query::<RoaringBitmap>(RegistryQuery::new(ObjectType::Account))
.await
.unwrap_or_default();
// inbuxa: MT-3: grants stay within the owner's tenant
let tenant_id = self
.try_account(account_id)
.await
.ok()
.flatten()
.and_then(|owner| owner.id_tenant);
for grant in grants {
if !principal_ids.contains(grant.account_id)
|| self
.try_account(grant.account_id)
.await
.ok()
.flatten()
.is_none_or(|grantee| grantee.id_tenant != tenant_id)
{
return Err(ShareValidationError::InvalidAccountId(Id::from(
grant.account_id,
)));
}
}
Ok(())
}
}
impl<T: Property> From<ShareValidationError> for SetError<T> {
fn from(err: ShareValidationError) -> Self {
match err {
ShareValidationError::MaxSharesExceeded(max) => SetError::invalid_properties()
.with_description(format!(
"Maximum number of shares per item exceeded (max: {max})"
)),
// inbuxa: MT-3: the same error whether the account is missing or
// in another tenant, so it never confirms the account exists
ShareValidationError::InvalidAccountId(id) => {
SetError::new(SetErrorType::InvalidForeignKey)
.with_object_id(ObjectId::new(ObjectType::Account, id))
.with_description(format!("Account id {id} is invalid."))
}
}
}
}