A school, or any organization that doesn't want people's mailboxes shared, can now turn that off (multi-account spec, MA-C). Two switches at two levels, as the legacy-protocols switch has: - mailSharing: people may share their own mail folders; - addAccounts: people may add other accounts to the webmail (read by the webmail's account switcher, MA-B). inbuxa:SharingPolicy/get and /set hold them: the server's policy has the singleton id, each tenant's has the tenant's id. Both default to on, so nothing changes until someone turns one off. A tenant's administrator changes their own tenant's (the domain's permissions, as for its protocols switch); only a server administrator with sysSharingUpdate changes the server's; a tenant can never be looser than the server (forbidden). Every change goes through the audit log, and rebuilds every access token, here and on every node. With mail sharing off for an account's tenant (or the server): - Mailbox/set and IMAP SETACL refuse to start or widen a share (forbidden / NO [NOPERM]); narrowing or ending one is always allowed; - shares already made give nothing while it is off: an access token leaves out mailbox grants from such an owner. They stay stored, so turning sharing back on restores them (John, 2026-10-05); - a lock's and a shared mailbox's grants are an administrator's and always count, and group membership was never a share. The session's own account says mailSharing and addAccounts, the stricter of the two levels, so front ends can hide what is off. Tests: a new sharing_policy suite with a school tenant, its own administrator and two people outside it: on by default; the school's administrator turns it off but can't touch the server's; an old share stops working and a new one is refused while someone outside the school is unaffected; a shared mailbox in the school keeps working; the server off can't be loosened by the tenant; on again restores the old share; ending a share works while off; and every change is audited. A unit test covers the stricter-only rule. sharing_policy_tests, jmap_tests, imap_tests, account_lock_tests and audit_log_tests pass (RocksDB).
566 lines
21 KiB
Rust
566 lines
21 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 crate::{
|
|
core::{MailboxId, Session, SessionData, State},
|
|
op::ImapContext,
|
|
spawn_op,
|
|
};
|
|
use common::{
|
|
auth::AccessToken, ipc::CacheInvalidation, network::SessionStream, sharing::EffectiveAcl,
|
|
storage::index::ObjectIndexBuilder,
|
|
};
|
|
use compact_str::ToCompactString;
|
|
use imap_proto::{
|
|
Command, ResponseCode, StatusResponse,
|
|
protocol::acl::{
|
|
Arguments, GetAclResponse, ListRightsResponse, ModRightsOp, MyRightsResponse, Rights,
|
|
},
|
|
receiver::Request,
|
|
};
|
|
use registry::schema::enums::Permission;
|
|
use std::time::Instant;
|
|
use store::{
|
|
ValueKey,
|
|
write::{AlignedBytes, Archive, BatchBuilder},
|
|
};
|
|
use trc::AddContext;
|
|
use types::{
|
|
acl::{Acl, AclGrant},
|
|
collection::Collection,
|
|
};
|
|
use utils::map::bitmap::Bitmap;
|
|
|
|
impl<T: SessionStream> Session<T> {
|
|
pub async fn handle_get_acl(&mut self, request: Request<Command>) -> trc::Result<()> {
|
|
// Validate access
|
|
self.assert_has_permission(Permission::ImapAclGet)?;
|
|
|
|
let op_start = Instant::now();
|
|
let arguments = request.parse_acl(self.is_utf8)?;
|
|
let is_utf8 = self.version.is_rev2() || self.is_utf8;
|
|
let data = self.state.session_data();
|
|
|
|
spawn_op!(data, {
|
|
let (mailbox_id, mailbox_, _) = data
|
|
.get_acl_mailbox(&arguments, true)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
let mut permissions = Vec::new();
|
|
let mailbox = mailbox_
|
|
.to_unarchived::<email::mailbox::Mailbox>()
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
|
|
// Add the current user if they are the owner or a group member
|
|
if data.access_token.is_member(mailbox_id.account_id) {
|
|
let account_name = data
|
|
.server
|
|
.account(mailbox_id.account_id)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?
|
|
.name()
|
|
.to_string();
|
|
|
|
permissions.push((
|
|
account_name,
|
|
vec![
|
|
Rights::Read,
|
|
Rights::Lookup,
|
|
Rights::Insert,
|
|
Rights::DeleteMessages,
|
|
Rights::Expunge,
|
|
Rights::Seen,
|
|
Rights::Write,
|
|
Rights::CreateMailbox,
|
|
Rights::DeleteMailbox,
|
|
Rights::Post,
|
|
Rights::Administer,
|
|
],
|
|
));
|
|
}
|
|
|
|
for item in mailbox.inner.acls.iter() {
|
|
if item.account_id == mailbox_id.account_id {
|
|
// Skip the current user, as they are already added above
|
|
continue;
|
|
}
|
|
|
|
let mut rights = Vec::new();
|
|
|
|
for acl in Bitmap::from(&item.grants) {
|
|
match acl {
|
|
Acl::Read => {
|
|
rights.push(Rights::Lookup);
|
|
}
|
|
Acl::Modify => {
|
|
rights.push(Rights::CreateMailbox);
|
|
}
|
|
Acl::Delete => {
|
|
rights.push(Rights::DeleteMailbox);
|
|
}
|
|
Acl::ReadItems => {
|
|
rights.push(Rights::Read);
|
|
}
|
|
Acl::AddItems => {
|
|
rights.push(Rights::Insert);
|
|
}
|
|
Acl::ModifyItems => {
|
|
rights.push(Rights::Write);
|
|
rights.push(Rights::Seen);
|
|
}
|
|
Acl::RemoveItems => {
|
|
rights.push(Rights::DeleteMessages);
|
|
rights.push(Rights::Expunge);
|
|
}
|
|
Acl::CreateChild => {
|
|
rights.push(Rights::CreateMailbox);
|
|
}
|
|
Acl::Share => {
|
|
rights.push(Rights::Administer);
|
|
}
|
|
Acl::Submit => {
|
|
rights.push(Rights::Post);
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
let account_name = data
|
|
.server
|
|
.account(item.account_id.into())
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?
|
|
.name()
|
|
.to_string();
|
|
|
|
permissions.push((account_name, rights));
|
|
}
|
|
|
|
trc::event!(
|
|
Imap(trc::ImapEvent::GetAcl),
|
|
SpanId = data.session_id,
|
|
MailboxName = arguments.mailbox_name.clone(),
|
|
AccountId = mailbox_id.account_id,
|
|
MailboxId = mailbox_id.mailbox_id,
|
|
Total = permissions.len(),
|
|
Elapsed = op_start.elapsed()
|
|
);
|
|
|
|
data.write_bytes(
|
|
StatusResponse::completed(Command::GetAcl)
|
|
.with_tag(arguments.tag)
|
|
.serialize(
|
|
GetAclResponse {
|
|
mailbox_name: arguments.mailbox_name.to_string(),
|
|
permissions,
|
|
}
|
|
.into_bytes(is_utf8),
|
|
),
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub async fn handle_my_rights(&mut self, request: Request<Command>) -> trc::Result<()> {
|
|
// Validate access
|
|
self.assert_has_permission(Permission::ImapMyRights)?;
|
|
|
|
let op_start = Instant::now();
|
|
let arguments = request.parse_acl(self.is_utf8)?;
|
|
let data = self.state.session_data();
|
|
let is_utf8 = self.version.is_rev2() || self.is_utf8;
|
|
|
|
spawn_op!(data, {
|
|
let (mailbox_id, mailbox_, access_token) = data
|
|
.get_acl_mailbox(&arguments, false)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
let mailbox = mailbox_
|
|
.to_unarchived::<email::mailbox::Mailbox>()
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
let rights = if access_token.is_shared(mailbox_id.account_id) {
|
|
let acl = mailbox.inner.acls.effective_acl(&access_token);
|
|
let mut rights = Vec::with_capacity(5);
|
|
if acl.contains(Acl::ReadItems) {
|
|
rights.push(Rights::Read);
|
|
rights.push(Rights::Lookup);
|
|
}
|
|
if acl.contains(Acl::AddItems) {
|
|
rights.push(Rights::Insert);
|
|
}
|
|
if acl.contains(Acl::RemoveItems) {
|
|
rights.push(Rights::DeleteMessages);
|
|
rights.push(Rights::Expunge);
|
|
}
|
|
if acl.contains(Acl::ModifyItems) {
|
|
rights.push(Rights::Seen);
|
|
rights.push(Rights::Write);
|
|
}
|
|
if acl.contains(Acl::CreateChild) {
|
|
rights.push(Rights::CreateMailbox);
|
|
}
|
|
if acl.contains(Acl::Delete) {
|
|
rights.push(Rights::DeleteMailbox);
|
|
}
|
|
if acl.contains(Acl::Submit) {
|
|
rights.push(Rights::Post);
|
|
}
|
|
rights
|
|
} else {
|
|
let mut rights = vec![
|
|
Rights::Read,
|
|
Rights::Lookup,
|
|
Rights::Insert,
|
|
Rights::DeleteMessages,
|
|
Rights::Expunge,
|
|
Rights::Seen,
|
|
Rights::Write,
|
|
Rights::CreateMailbox,
|
|
Rights::DeleteMailbox,
|
|
Rights::Post,
|
|
];
|
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
|
if !access_token.is_group_member_only(mailbox_id.account_id) {
|
|
rights.push(Rights::Administer);
|
|
}
|
|
rights
|
|
};
|
|
|
|
trc::event!(
|
|
Imap(trc::ImapEvent::MyRights),
|
|
SpanId = data.session_id,
|
|
MailboxName = arguments.mailbox_name.clone(),
|
|
AccountId = mailbox_id.account_id,
|
|
MailboxId = mailbox_id.mailbox_id,
|
|
Details = rights
|
|
.iter()
|
|
.map(|r| trc::Value::String(r.to_compact_string()))
|
|
.collect::<Vec<_>>(),
|
|
Elapsed = op_start.elapsed()
|
|
);
|
|
|
|
data.write_bytes(
|
|
StatusResponse::completed(Command::MyRights)
|
|
.with_tag(arguments.tag)
|
|
.serialize(
|
|
MyRightsResponse {
|
|
mailbox_name: arguments.mailbox_name.to_string(),
|
|
rights,
|
|
}
|
|
.into_bytes(is_utf8),
|
|
),
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub async fn handle_set_acl(&mut self, request: Request<Command>) -> trc::Result<()> {
|
|
// Validate access
|
|
self.assert_has_permission(Permission::ImapAclSet)?;
|
|
|
|
let op_start = Instant::now();
|
|
let command = request.command;
|
|
let arguments = request.parse_acl(self.is_utf8)?;
|
|
let data = self.state.session_data();
|
|
|
|
spawn_op!(data, {
|
|
// Validate mailbox
|
|
let (mailbox_id, current_mailbox, access_token) = data
|
|
.get_acl_mailbox(&arguments, true)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
|
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
|
if access_token.is_group_member_only(mailbox_id.account_id) {
|
|
return Err(trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("This mailbox belongs to a group. Only an administrator can change who has it.")
|
|
.code(ResponseCode::NoPerm)
|
|
.id(arguments.tag.to_string()));
|
|
}
|
|
|
|
let current_mailbox = current_mailbox
|
|
.into_deserialized::<email::mailbox::Mailbox>()
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
|
|
// Obtain principal id
|
|
let acl_account_id = data
|
|
.server
|
|
.account_id_from_email(arguments.identifier.as_ref().unwrap(), false)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?
|
|
.ok_or_else(|| {
|
|
trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("Account does not exist")
|
|
.id(arguments.tag.to_string())
|
|
.caused_by(trc::location!())
|
|
})?;
|
|
|
|
// inbuxa: MT-3: grants stay within the owner's tenant, refused
|
|
// as if the account didn't exist
|
|
let owner_tenant = data
|
|
.server
|
|
.try_account(mailbox_id.account_id)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?
|
|
.and_then(|owner| owner.id_tenant);
|
|
if data
|
|
.server
|
|
.try_account(acl_account_id)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?
|
|
.is_none_or(|grantee| grantee.id_tenant != owner_tenant)
|
|
{
|
|
return Err(trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("Account does not exist")
|
|
.id(arguments.tag.to_string())
|
|
.caused_by(trc::location!()));
|
|
}
|
|
|
|
// Prepare changes
|
|
let mut mailbox = current_mailbox.inner.clone();
|
|
let (op, rights) = arguments
|
|
.mod_rights
|
|
.map(|mr| {
|
|
(
|
|
mr.op,
|
|
Bitmap::from_iter(mr.rights.into_iter().map(Acl::from)),
|
|
)
|
|
})
|
|
.unwrap_or_else(|| (ModRightsOp::Replace, Bitmap::new()));
|
|
|
|
if let Some(item) = mailbox
|
|
.acls
|
|
.iter_mut()
|
|
.find(|item| item.account_id == acl_account_id)
|
|
{
|
|
match op {
|
|
ModRightsOp::Replace => {
|
|
if !rights.is_empty() {
|
|
item.grants = rights;
|
|
} else {
|
|
mailbox
|
|
.acls
|
|
.retain(|item| item.account_id != acl_account_id);
|
|
}
|
|
}
|
|
ModRightsOp::Add => {
|
|
item.grants.union(&rights);
|
|
}
|
|
ModRightsOp::Remove => {
|
|
for right in rights {
|
|
item.grants.remove(right);
|
|
}
|
|
if item.grants.is_empty() {
|
|
mailbox
|
|
.acls
|
|
.retain(|item| item.account_id != acl_account_id);
|
|
}
|
|
}
|
|
}
|
|
} else if !rights.is_empty() {
|
|
match op {
|
|
ModRightsOp::Add | ModRightsOp::Replace => {
|
|
mailbox.acls.push(AclGrant {
|
|
account_id: acl_account_id,
|
|
grants: rights,
|
|
});
|
|
}
|
|
ModRightsOp::Remove => (),
|
|
}
|
|
}
|
|
|
|
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
|
// widens a share (narrowing or ending one is always allowed)
|
|
let had = current_mailbox
|
|
.inner
|
|
.acls
|
|
.iter()
|
|
.find(|item| item.account_id == acl_account_id)
|
|
.map_or(0, |item| item.grants.clone().into_inner());
|
|
let has = mailbox
|
|
.acls
|
|
.iter()
|
|
.find(|item| item.account_id == acl_account_id)
|
|
.map_or(0, |item| item.grants.clone().into_inner());
|
|
if has & !had != 0
|
|
&& !access_token.has_permission(Permission::Impersonate)
|
|
&& !data
|
|
.server
|
|
.mail_sharing_allowed(mailbox_id.account_id)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?
|
|
{
|
|
return Err(trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("Your organization has turned off sharing mail folders.")
|
|
.code(ResponseCode::NoPerm)
|
|
.id(arguments.tag.to_string()));
|
|
}
|
|
|
|
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
|
return Err(trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("Maximum shares per item exceeded")
|
|
.caused_by(trc::location!()));
|
|
}
|
|
|
|
let grants = mailbox
|
|
.acls
|
|
.iter()
|
|
.map(|r| trc::Value::from(r.account_id))
|
|
.collect::<Vec<_>>();
|
|
|
|
// Write changes
|
|
let mut batch = BatchBuilder::new();
|
|
batch
|
|
.with_account_id(mailbox_id.account_id)
|
|
.with_collection(Collection::Mailbox)
|
|
.with_document(mailbox_id.mailbox_id)
|
|
.custom(
|
|
ObjectIndexBuilder::new()
|
|
.with_changes(mailbox)
|
|
.with_current(current_mailbox),
|
|
)
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
|
|
if !batch.is_empty() {
|
|
data.server
|
|
.commit_batch(batch)
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
}
|
|
|
|
// Invalidate ACLs
|
|
data.server
|
|
.invalidate_caches(CacheInvalidation::AccessToken(acl_account_id).into())
|
|
.await
|
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
|
|
|
trc::event!(
|
|
Imap(trc::ImapEvent::SetAcl),
|
|
SpanId = data.session_id,
|
|
MailboxName = arguments.mailbox_name.clone(),
|
|
AccountId = mailbox_id.account_id,
|
|
MailboxId = mailbox_id.mailbox_id,
|
|
Details = grants,
|
|
Elapsed = op_start.elapsed()
|
|
);
|
|
|
|
data.write_bytes(
|
|
StatusResponse::completed(command)
|
|
.with_tag(arguments.tag)
|
|
.into_bytes(),
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
|
|
pub async fn handle_list_rights(&mut self, request: Request<Command>) -> trc::Result<()> {
|
|
// Validate access
|
|
self.assert_has_permission(Permission::ImapListRights)?;
|
|
|
|
let op_start = Instant::now();
|
|
let arguments = request.parse_acl(self.is_utf8)?;
|
|
|
|
trc::event!(
|
|
Imap(trc::ImapEvent::ListRights),
|
|
SpanId = self.session_id,
|
|
MailboxName = arguments.mailbox_name.clone(),
|
|
Elapsed = op_start.elapsed()
|
|
);
|
|
|
|
self.write_bytes(
|
|
StatusResponse::completed(Command::ListRights)
|
|
.with_tag(arguments.tag)
|
|
.serialize(
|
|
ListRightsResponse {
|
|
mailbox_name: arguments.mailbox_name,
|
|
identifier: arguments.identifier.unwrap(),
|
|
permissions: vec![
|
|
vec![Rights::Read],
|
|
vec![Rights::Lookup],
|
|
vec![Rights::Write, Rights::Seen],
|
|
vec![Rights::Insert],
|
|
vec![Rights::Expunge, Rights::DeleteMessages],
|
|
vec![Rights::CreateMailbox],
|
|
vec![Rights::DeleteMailbox],
|
|
vec![Rights::Post],
|
|
vec![Rights::Administer],
|
|
],
|
|
}
|
|
.into_bytes(self.version.is_rev2() || self.is_utf8),
|
|
),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub fn assert_has_permission(&self, permission: Permission) -> trc::Result<bool> {
|
|
match &self.state {
|
|
State::Authenticated { data } | State::Selected { data, .. } => data
|
|
.access_token
|
|
.enforce_permission(permission)
|
|
.map(|_| true),
|
|
State::NotAuthenticated { .. } => Ok(false),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: SessionStream> SessionData<T> {
|
|
async fn get_acl_mailbox(
|
|
&self,
|
|
arguments: &Arguments,
|
|
validate: bool,
|
|
) -> trc::Result<(MailboxId, Archive<AlignedBytes>, AccessToken)> {
|
|
if let Some(mailbox) = self.get_mailbox_by_name(&arguments.mailbox_name) {
|
|
if let Some(values) = self
|
|
.server
|
|
.store()
|
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
|
mailbox.account_id,
|
|
Collection::Mailbox,
|
|
mailbox.mailbox_id,
|
|
))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
let access_token = self
|
|
.refresh_access_token()
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
if !validate
|
|
|| access_token.is_member(mailbox.account_id)
|
|
|| values
|
|
.unarchive::<email::mailbox::Mailbox>()
|
|
.caused_by(trc::location!())?
|
|
.acls
|
|
.effective_acl(&access_token)
|
|
.contains(Acl::Share)
|
|
{
|
|
Ok((mailbox, values, access_token))
|
|
} else {
|
|
Err(trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("You do not have enough permissions to perform this operation.")
|
|
.code(ResponseCode::NoPerm))
|
|
}
|
|
} else {
|
|
Err(trc::ImapEvent::Error
|
|
.caused_by(trc::location!())
|
|
.details("Mailbox does not exist."))
|
|
}
|
|
} else {
|
|
Err(trc::ImapEvent::Error
|
|
.into_err()
|
|
.details("Mailbox does not exist."))
|
|
}
|
|
}
|
|
}
|