Merge pull request 'Locked accounts: keep receiving mail, no sign-in, hand to delegates' (#65) from feature/account-lock into main
This commit was merged in pull request #65.
This commit is contained in:
@@ -35,6 +35,7 @@ const KIND_ACCOUNT_ACCESS: u8 = 0;
|
|||||||
const KIND_BLOB_ACCESS: u8 = 1;
|
const KIND_BLOB_ACCESS: u8 = 1;
|
||||||
const KIND_SIGN_IN: u8 = 2;
|
const KIND_SIGN_IN: u8 = 2;
|
||||||
const KIND_SIGN_IN_FAILED: u8 = 3;
|
const KIND_SIGN_IN_FAILED: u8 = 3;
|
||||||
|
const KIND_DELEGATE_ACCESS: u8 = 4;
|
||||||
|
|
||||||
/// The permissions that make an account an administrator for AU-1.4: every
|
/// The permissions that make an account an administrator for AU-1.4: every
|
||||||
/// `sys*` permission a plain user doesn't get by default, and impersonation.
|
/// `sys*` permission a plain user doesn't get by default, and impersonation.
|
||||||
@@ -369,6 +370,80 @@ impl Server {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AL-9: a delegate reaching a locked account: its access once an hour,
|
||||||
|
/// and every change it makes there, one record per method call.
|
||||||
|
pub async fn audit_delegate(
|
||||||
|
&self,
|
||||||
|
token: &AccessToken,
|
||||||
|
locked_id: u32,
|
||||||
|
access: &str,
|
||||||
|
write: Option<&str>,
|
||||||
|
error: Option<&trc::Error>,
|
||||||
|
) {
|
||||||
|
let first = self.audit().first_access_this_hour(
|
||||||
|
token.account_id(),
|
||||||
|
locked_id,
|
||||||
|
KIND_DELEGATE_ACCESS,
|
||||||
|
now(),
|
||||||
|
);
|
||||||
|
if !first && write.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let actor = self.audit_actor(token).await;
|
||||||
|
let target = Target {
|
||||||
|
kind: "account".into(),
|
||||||
|
id: Some(Id::from(locked_id).to_string()),
|
||||||
|
name: Some(self.audit_account_name(locked_id).await),
|
||||||
|
account_id: Some(locked_id),
|
||||||
|
tenant_id: self
|
||||||
|
.account(locked_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|account| account.id_tenant),
|
||||||
|
};
|
||||||
|
let mut records = Vec::new();
|
||||||
|
if first {
|
||||||
|
records.push(Record {
|
||||||
|
at: ms(),
|
||||||
|
actor: actor.clone(),
|
||||||
|
via: token.origin().cloned(),
|
||||||
|
remote_ip: None,
|
||||||
|
action: Action::AccountAccess,
|
||||||
|
target: target.clone(),
|
||||||
|
changes: vec![],
|
||||||
|
details: Some(format!("As a delegate ({access})")),
|
||||||
|
reason: None,
|
||||||
|
outcome: Outcome::success(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(method) = write {
|
||||||
|
records.push(Record {
|
||||||
|
at: ms(),
|
||||||
|
actor,
|
||||||
|
via: token.origin().cloned(),
|
||||||
|
remote_ip: None,
|
||||||
|
action: Action::Update,
|
||||||
|
target,
|
||||||
|
changes: vec![],
|
||||||
|
details: Some(format!("{method} as a delegate ({access})")),
|
||||||
|
reason: None,
|
||||||
|
outcome: match error {
|
||||||
|
None => Outcome::success(),
|
||||||
|
Some(err) => Outcome::refused(
|
||||||
|
"error",
|
||||||
|
err.value_as_str(trc::Key::Details).map(str::to_string),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for record in records {
|
||||||
|
if !self.audit_note(record).await && first {
|
||||||
|
self.audit()
|
||||||
|
.forget_access(token.account_id(), locked_id, KIND_DELEGATE_ACCESS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AU-7: removes entries past the retention period.
|
/// AU-7: removes entries past the retention period.
|
||||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||||
let settings = log::settings(self.store()).await?;
|
let settings = log::settings(self.store()).await?;
|
||||||
|
|||||||
@@ -43,6 +43,27 @@ impl Server {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
revision_account: u64,
|
revision_account: u64,
|
||||||
) -> trc::Result<AccessTokenInner> {
|
) -> trc::Result<AccessTokenInner> {
|
||||||
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
||||||
|
// locked accounts are handed to it. The token is their cache: every
|
||||||
|
// change to a lock invalidates the tokens it touches.
|
||||||
|
let locked = inbuxa_features::lock::get(self.store(), account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_some();
|
||||||
|
let now_secs = now();
|
||||||
|
let delegations: Box<[super::Delegation]> =
|
||||||
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
||||||
|
.map(|(locked_id, delegate)| super::Delegation {
|
||||||
|
account_id: locked_id,
|
||||||
|
access: delegate.access,
|
||||||
|
send_as: delegate.send_as,
|
||||||
|
until: delegate.until,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
match account {
|
match account {
|
||||||
Account::User(account) => {
|
Account::User(account) => {
|
||||||
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
|
let tenant_id = account.member_tenant_id.map(|t| t.id() as u32);
|
||||||
@@ -202,6 +223,8 @@ impl Server {
|
|||||||
.upload_max_concurrent
|
.upload_max_concurrent
|
||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
|
locked,
|
||||||
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
credential_version,
|
credential_version,
|
||||||
@@ -211,7 +234,15 @@ impl Server {
|
|||||||
access_to: access_to.into_boxed_slice(),
|
access_to: access_to.into_boxed_slice(),
|
||||||
scopes: []
|
scopes: []
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(credential_scopes)
|
.chain(credential_scopes.into_iter().map(|mut scope| {
|
||||||
|
// inbuxa: AL-2: no credential of a locked
|
||||||
|
// account authenticates; receiving mail isn't
|
||||||
|
// signing in, so EmailReceive stays
|
||||||
|
if locked {
|
||||||
|
scope.permissions.clear(Permission::Authenticate as usize);
|
||||||
|
}
|
||||||
|
scope
|
||||||
|
}))
|
||||||
.collect::<Box<[AccessScope]>>(),
|
.collect::<Box<[AccessScope]>>(),
|
||||||
}
|
}
|
||||||
.update_size())
|
.update_size())
|
||||||
@@ -245,6 +276,8 @@ impl Server {
|
|||||||
.upload_max_concurrent
|
.upload_max_concurrent
|
||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
|
locked,
|
||||||
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
credential_version: 0,
|
credential_version: 0,
|
||||||
@@ -591,6 +624,8 @@ impl AccessToken {
|
|||||||
revision: old_inner.revision,
|
revision: old_inner.revision,
|
||||||
credential_version: old_inner.credential_version,
|
credential_version: old_inner.credential_version,
|
||||||
obj_size: old_inner.obj_size,
|
obj_size: old_inner.obj_size,
|
||||||
|
locked: old_inner.locked,
|
||||||
|
delegations: old_inner.delegations.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
access_token = AccessToken {
|
access_token = AccessToken {
|
||||||
@@ -775,6 +810,30 @@ impl AccessToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: AL-2: the account is locked.
|
||||||
|
pub fn is_locked(&self) -> bool {
|
||||||
|
self.inner.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
|
/// has one that hasn't ended.
|
||||||
|
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
|
||||||
|
let now = now();
|
||||||
|
self.inner
|
||||||
|
.delegations
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.account_id == account_id && d.until.is_none_or(|until| until > now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: AL-5: every current delegation this account holds.
|
||||||
|
pub fn delegations(&self) -> impl Iterator<Item = &super::Delegation> {
|
||||||
|
let now = now();
|
||||||
|
self.inner
|
||||||
|
.delegations
|
||||||
|
.iter()
|
||||||
|
.filter(move |d| d.until.is_none_or(|until| until > now))
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: how this session signed in (AU-5).
|
/// inbuxa: how this session signed in (AU-5).
|
||||||
pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> {
|
pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> {
|
||||||
self.origin.as_deref()
|
self.origin.as_deref()
|
||||||
@@ -828,6 +887,8 @@ impl AccessToken {
|
|||||||
revision_account: Default::default(),
|
revision_account: Default::default(),
|
||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
|
locked: false,
|
||||||
|
delegations: Default::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -838,6 +899,11 @@ impl AccessToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AccessTokenInner {
|
impl AccessTokenInner {
|
||||||
|
/// inbuxa: AL-2: the account is locked.
|
||||||
|
pub fn is_locked(&self) -> bool {
|
||||||
|
self.locked
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: SCIM-27: the account's own effective permission, from its
|
/// inbuxa: SCIM-27: the account's own effective permission, from its
|
||||||
/// roles, its own settings and its tenant, before a credential narrows it
|
/// roles, its own settings and its tenant, before a credential narrows it
|
||||||
pub fn account_has_permission(&self, permission: Permission) -> bool {
|
pub fn account_has_permission(&self, permission: Permission) -> bool {
|
||||||
@@ -881,6 +947,8 @@ impl AccessTokenInner {
|
|||||||
revision_account: Default::default(),
|
revision_account: Default::default(),
|
||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
|
locked: false,
|
||||||
|
delegations: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ impl Server {
|
|||||||
pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result<AccessToken> {
|
pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result<AccessToken> {
|
||||||
match Box::pin(self.route_auth_request(req))
|
match Box::pin(self.route_auth_request(req))
|
||||||
.await
|
.await
|
||||||
|
// inbuxa: AL-2: a locked account fails as a wrong password does,
|
||||||
|
// so the right password learns nothing; master and recovery
|
||||||
|
// sign-ins as it fail the same way
|
||||||
|
.and_then(|token| {
|
||||||
|
if token.is_locked() {
|
||||||
|
Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::AccountId, token.account_id())
|
||||||
|
.reason("Account is locked"))
|
||||||
|
} else {
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
})
|
||||||
.and_then(|token| token.assert_has_permission(Permission::Authenticate))
|
.and_then(|token| token.assert_has_permission(Permission::Authenticate))
|
||||||
{
|
{
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
|
|||||||
@@ -150,6 +150,21 @@ pub struct AccessTokenInner {
|
|||||||
pub(crate) revision: u64,
|
pub(crate) revision: u64,
|
||||||
pub(crate) credential_version: u64,
|
pub(crate) credential_version: u64,
|
||||||
pub(crate) obj_size: u64,
|
pub(crate) obj_size: u64,
|
||||||
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
||||||
|
pub(crate) locked: bool,
|
||||||
|
// inbuxa: AL-5: locked accounts handed to this one
|
||||||
|
pub(crate) delegations: Box<[Delegation]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: a locked account this one may open, and how (AL-5, AL-6).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Delegation {
|
||||||
|
/// The locked account.
|
||||||
|
pub account_id: u32,
|
||||||
|
pub access: inbuxa_features::lock::Access,
|
||||||
|
pub send_as: bool,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub until: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Hash, Clone)]
|
#[derive(Debug, Default, Hash, Clone)]
|
||||||
|
|||||||
@@ -275,6 +275,15 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.push(permission);
|
default.tenant.push(permission);
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||||
|
// within their tenant
|
||||||
|
Permission::SysAccountLockGet
|
||||||
|
| Permission::SysAccountLockCreate
|
||||||
|
| Permission::SysAccountLockUpdate
|
||||||
|
| Permission::SysAccountLockDestroy => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
default.tenant.push(permission);
|
||||||
|
}
|
||||||
permission => {
|
permission => {
|
||||||
let name = permission.as_str();
|
let name = permission.as_str();
|
||||||
if name.starts_with("jmap")
|
if name.starts_with("jmap")
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ pub enum BroadcastEvent {
|
|||||||
CacheInvalidateNegative,
|
CacheInvalidateNegative,
|
||||||
MtaQueueStatus { is_running: bool },
|
MtaQueueStatus { is_running: bool },
|
||||||
QueueRefresh,
|
QueueRefresh,
|
||||||
|
// inbuxa: AL-3: end an account's open sessions on every node
|
||||||
|
EndSessions(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -36,11 +36,23 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
Permission::SysAuditSettingsUpdate,
|
Permission::SysAuditSettingsUpdate,
|
||||||
|
Permission::SysAccountLockGet,
|
||||||
|
Permission::SysAccountLockCreate,
|
||||||
|
Permission::SysAccountLockUpdate,
|
||||||
|
Permission::SysAccountLockDestroy,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the default tenant administrator roles: reading and exporting
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9).
|
/// the tenant's audit log (AU-9), and locking and delegating its accounts
|
||||||
const TENANT_GRANTS: &[Permission] = &[Permission::SysAuditGet, Permission::SysAuditExport];
|
/// (AL-12).
|
||||||
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
|
Permission::SysAuditGet,
|
||||||
|
Permission::SysAuditExport,
|
||||||
|
Permission::SysAccountLockGet,
|
||||||
|
Permission::SysAccountLockCreate,
|
||||||
|
Permission::SysAccountLockUpdate,
|
||||||
|
Permission::SysAccountLockDestroy,
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Audience {
|
enum Audience {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::proppatch::FilePropPatchRequestHandler;
|
use super::proppatch::FilePropPatchRequestHandler;
|
||||||
@@ -131,6 +133,14 @@ impl FileMkColRequestHandler for Server {
|
|||||||
let etag = batch.etag();
|
let etag = batch.etag();
|
||||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: AL-7: a folder a delegate makes in a locked account gets
|
||||||
|
// the lock's grants
|
||||||
|
if account_id != access_token.account_id()
|
||||||
|
&& let Err(err) = groupware::inbuxa_lock::reconcile_dav(self, account_id).await
|
||||||
|
{
|
||||||
|
trc::error!(err.details("Failed to grant a lock's delegates on a new folder"));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(prop_stat) = return_prop_stat {
|
if let Some(prop_stat) = return_prop_stat {
|
||||||
Ok(HttpResponse::new(StatusCode::CREATED)
|
Ok(HttpResponse::new(StatusCode::CREATED)
|
||||||
.with_xml_body(
|
.with_xml_body(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -299,6 +301,14 @@ impl FileUpdateRequestHandler for Server {
|
|||||||
let etag = batch.etag();
|
let etag = batch.etag();
|
||||||
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
self.commit_batch(batch).await.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: AL-7: a top-level file a delegate adds to a locked
|
||||||
|
// account gets the lock's grants
|
||||||
|
if account_id != access_token.account_id()
|
||||||
|
&& let Err(err) = groupware::inbuxa_lock::reconcile_dav(self, account_id).await
|
||||||
|
{
|
||||||
|
trc::error!(err.details("Failed to grant a lock's delegates on a new file"));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
|
Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! inbuxa: a locked account's grants, whole (audit-hold-lock spec, AL-7,
|
||||||
|
//! AL-10): its mailboxes here, and its calendars, address books and files
|
||||||
|
//! through `groupware::inbuxa_lock`.
|
||||||
|
//!
|
||||||
|
//! A delegate's access is real ACL grants on the locked account's
|
||||||
|
//! containers, the sharing IMAP, DAV and JMAP already honor, so a delegate
|
||||||
|
//! sees the account as a shared one everywhere. The lock notes what each
|
||||||
|
//! delegate had on a container before, so ending a delegation or the lock
|
||||||
|
//! puts it back. Idempotent: run again, it grants on containers made since
|
||||||
|
//! and changes nothing else.
|
||||||
|
|
||||||
|
use crate::{cache::MessageCacheFetch, mailbox::Mailbox};
|
||||||
|
use common::{Server, storage::index::ObjectIndexBuilder};
|
||||||
|
use groupware::inbuxa_lock::{apply_dav_grants, invalidate, same_replaced};
|
||||||
|
use inbuxa_features::lock::{self, Lock, Replaced};
|
||||||
|
use store::{
|
||||||
|
ValueKey,
|
||||||
|
write::{AlignedBytes, Archive, BatchBuilder, now},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
use types::{collection::Collection, special_use::SpecialUse};
|
||||||
|
|
||||||
|
/// Grants a lock's delegates their rights on every container of the locked
|
||||||
|
/// account, and takes away those of delegations that ended. Returns what the
|
||||||
|
/// lock now has to remember.
|
||||||
|
pub async fn apply_grants(
|
||||||
|
server: &Server,
|
||||||
|
account_id: u32,
|
||||||
|
old: Option<&Lock>,
|
||||||
|
new: Option<&Lock>,
|
||||||
|
) -> trc::Result<Vec<Replaced>> {
|
||||||
|
let now = now();
|
||||||
|
let mut replaced = Vec::new();
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
|
||||||
|
let cache = server
|
||||||
|
.get_cached_messages(account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for mailbox in cache.mailboxes.items.iter() {
|
||||||
|
// Mail in Trash and Junk is destroyed in time: an organizing
|
||||||
|
// delegate may look, not move mail in
|
||||||
|
let is_trash = matches!(mailbox.role, SpecialUse::Trash | SpecialUse::Junk);
|
||||||
|
let current = mailbox.acls.to_vec();
|
||||||
|
let Some(acls) = lock::merge_grants(
|
||||||
|
¤t,
|
||||||
|
Collection::Mailbox,
|
||||||
|
mailbox.document_id,
|
||||||
|
is_trash,
|
||||||
|
old,
|
||||||
|
new,
|
||||||
|
now,
|
||||||
|
&mut replaced,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(archive) = server
|
||||||
|
.store()
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::Mailbox,
|
||||||
|
mailbox.document_id,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let current = archive
|
||||||
|
.into_deserialized::<Mailbox>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let mut changed = current.inner.clone();
|
||||||
|
changed.acls = acls;
|
||||||
|
batch
|
||||||
|
.with_account_id(account_id)
|
||||||
|
.with_collection(Collection::Mailbox)
|
||||||
|
.with_document(mailbox.document_id)
|
||||||
|
.custom(
|
||||||
|
ObjectIndexBuilder::new()
|
||||||
|
.with_changes(changed)
|
||||||
|
.with_current(current),
|
||||||
|
)
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_dav_grants(server, account_id, old, new, now, &mut replaced, &mut batch).await?;
|
||||||
|
|
||||||
|
if !batch.is_empty() {
|
||||||
|
server
|
||||||
|
.commit_batch(batch)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
Ok(replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-applies the lock on `account_id`, if any, so containers made since get
|
||||||
|
/// its grants: after a delegate creates something there, and daily.
|
||||||
|
pub async fn reconcile(server: &Server, account_id: u32) -> trc::Result<()> {
|
||||||
|
let data = server.store();
|
||||||
|
let Some(current) = lock::get(data, account_id).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let replaced = apply_grants(server, account_id, Some(¤t), Some(¤t)).await?;
|
||||||
|
if !same_replaced(&replaced, ¤t.replaced) {
|
||||||
|
let updated = Lock {
|
||||||
|
replaced,
|
||||||
|
..current.clone()
|
||||||
|
};
|
||||||
|
lock::set(data, &updated, Some(¤t)).await?;
|
||||||
|
}
|
||||||
|
invalidate(server, account_id, Some(¤t), Some(¤t)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-applies every lock: the daily sweep, for containers made by the server
|
||||||
|
/// itself (a Sieve `fileinto :create`) rather than by a delegate.
|
||||||
|
pub async fn reconcile_all(server: &Server) -> trc::Result<()> {
|
||||||
|
for current in lock::all(server.store()).await? {
|
||||||
|
reconcile(server, current.account_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
pub mod inbuxa_lock; // inbuxa: account lock grants
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod push;
|
pub mod push;
|
||||||
|
|||||||
@@ -287,6 +287,18 @@ impl SieveScriptIngest for Server {
|
|||||||
do_discard = true;
|
do_discard = true;
|
||||||
input = true.into();
|
input = true.into();
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-4: a locked account answers no sender, so a
|
||||||
|
// rejection is kept instead; sieve has already cleared
|
||||||
|
// the implicit keep, so it is filed here
|
||||||
|
Event::Reject { .. } if access_token.is_locked() => {
|
||||||
|
if let Some(message) = messages.get_mut(0)
|
||||||
|
&& !message.file_into.contains(&INBOX_ID)
|
||||||
|
{
|
||||||
|
message.file_into.push(INBOX_ID);
|
||||||
|
}
|
||||||
|
do_deliver = true;
|
||||||
|
input = true.into();
|
||||||
|
}
|
||||||
Event::Reject { reason, .. } => {
|
Event::Reject { reason, .. } => {
|
||||||
reject_reason = reason.into();
|
reject_reason = reason.into();
|
||||||
do_discard = true;
|
do_discard = true;
|
||||||
@@ -388,6 +400,17 @@ impl SieveScriptIngest for Server {
|
|||||||
}
|
}
|
||||||
input = true.into();
|
input = true.into();
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-4: a locked account sends nothing on its
|
||||||
|
// own: no redirect, vacation reply or notification. An
|
||||||
|
// unsent redirect leaves the message to be kept.
|
||||||
|
Event::SendMessage { .. } if access_token.is_locked() => {
|
||||||
|
trc::event!(
|
||||||
|
Sieve(SieveEvent::ActionReject),
|
||||||
|
Details = "Account is locked: nothing is sent",
|
||||||
|
SpanId = session_id
|
||||||
|
);
|
||||||
|
input = true.into();
|
||||||
|
}
|
||||||
Event::SendMessage {
|
Event::SendMessage {
|
||||||
recipient,
|
recipient,
|
||||||
message_id,
|
message_id,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
|
pub mod lock;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod tenancy;
|
pub mod tenancy;
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Account lock with delegation (audit-hold-lock spec, AL-1 to AL-12).
|
||||||
|
//!
|
||||||
|
//! A locked account keeps receiving mail but can't sign in, by any means,
|
||||||
|
//! and sends nothing on its own. Delegates open it as a separate account,
|
||||||
|
//! through real ACL grants on its containers (the sharing every protocol
|
||||||
|
//! already honors), at a level the administrator chose.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||||
|
//! with `K`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `l` + account: the lock, as JSON.
|
||||||
|
//! - `d` + delegate + account: an index, so a delegate's access token can
|
||||||
|
//! find the accounts delegated to it with one scan.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian. Nothing is cached in memory: the access token is
|
||||||
|
//! the cache, built from these keys and invalidated on every change.
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
use types::{
|
||||||
|
acl::{Acl, AclGrant},
|
||||||
|
collection::Collection,
|
||||||
|
};
|
||||||
|
use utils::map::bitmap::Bitmap;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'K';
|
||||||
|
const KIND_LOCK: u8 = b'l';
|
||||||
|
const KIND_DELEGATE: u8 = b'd';
|
||||||
|
|
||||||
|
/// Most delegates one lock may have (AL-5).
|
||||||
|
pub const MAX_DELEGATES: usize = 10;
|
||||||
|
|
||||||
|
/// What a delegate may do in the locked account (AL-6).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Access {
|
||||||
|
/// See and download everything; change nothing, not even `$seen`.
|
||||||
|
Read,
|
||||||
|
/// Read, set keywords, move mail and create and rename folders; never
|
||||||
|
/// destroy.
|
||||||
|
Organize,
|
||||||
|
/// Everything the owner could do. Deletions are still kept under a hold.
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Access {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Access::Read => "read",
|
||||||
|
Access::Organize => "organize",
|
||||||
|
Access::Full => "full",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"read" => Some(Access::Read),
|
||||||
|
"organize" => Some(Access::Organize),
|
||||||
|
"full" => Some(Access::Full),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a delegate at this level may destroy anything.
|
||||||
|
pub fn may_destroy(&self) -> bool {
|
||||||
|
matches!(self, Access::Full)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rights granted on one container. `is_trash` marks a mailbox with
|
||||||
|
/// the Trash or Junk role: an organizing delegate may read it, but not
|
||||||
|
/// move mail into it, since mail there is destroyed in time.
|
||||||
|
pub fn grants(&self, collection: Collection, is_trash: bool) -> Bitmap<Acl> {
|
||||||
|
let read = [Acl::Read, Acl::ReadItems];
|
||||||
|
let rights: &[Acl] = match (self, collection) {
|
||||||
|
(Access::Read, _) => &read,
|
||||||
|
(Access::Organize, Collection::Mailbox) if is_trash => &read,
|
||||||
|
(Access::Organize, Collection::Mailbox) => &[
|
||||||
|
Acl::Read,
|
||||||
|
Acl::ReadItems,
|
||||||
|
Acl::Modify,
|
||||||
|
Acl::AddItems,
|
||||||
|
Acl::ModifyItems,
|
||||||
|
Acl::RemoveItems,
|
||||||
|
Acl::CreateChild,
|
||||||
|
],
|
||||||
|
// Calendars, address books and files have no "move": organizing
|
||||||
|
// there is adding and changing, never removing
|
||||||
|
(Access::Organize, _) => &[
|
||||||
|
Acl::Read,
|
||||||
|
Acl::ReadItems,
|
||||||
|
Acl::AddItems,
|
||||||
|
Acl::ModifyItems,
|
||||||
|
Acl::CreateChild,
|
||||||
|
],
|
||||||
|
(Access::Full, _) => &[
|
||||||
|
Acl::Read,
|
||||||
|
Acl::Modify,
|
||||||
|
Acl::Delete,
|
||||||
|
Acl::ReadItems,
|
||||||
|
Acl::AddItems,
|
||||||
|
Acl::ModifyItems,
|
||||||
|
Acl::RemoveItems,
|
||||||
|
Acl::CreateChild,
|
||||||
|
Acl::Submit,
|
||||||
|
Acl::ModifyItemsOwn,
|
||||||
|
Acl::ModifyPrivateProperties,
|
||||||
|
Acl::ModifyRSVP,
|
||||||
|
Acl::SchedulingReadFreeBusy,
|
||||||
|
Acl::SchedulingInvite,
|
||||||
|
Acl::SchedulingReply,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
Bitmap::from_iter(rights.iter().copied())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One person the locked account is handed to (AL-5).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Delegate {
|
||||||
|
pub account_id: u32,
|
||||||
|
pub access: Access,
|
||||||
|
/// May send from the locked account's identities (AL-8). Needs
|
||||||
|
/// `organize` or `full`: a message is made in its Drafts first.
|
||||||
|
#[serde(default)]
|
||||||
|
pub send_as: bool,
|
||||||
|
/// Seconds since the epoch; the delegation ends then on its own.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub until: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Delegate {
|
||||||
|
pub fn is_current(&self, now: u64) -> bool {
|
||||||
|
self.until.is_none_or(|until| until > now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delegate's rights a lock replaced on one container, put back when the
|
||||||
|
/// lock or that delegation ends (AL-10). A container with no entry had no
|
||||||
|
/// grant for that delegate before.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Replaced {
|
||||||
|
pub collection: u8,
|
||||||
|
pub document_id: u32,
|
||||||
|
pub delegate: u32,
|
||||||
|
/// The rights as a bitmap's raw value.
|
||||||
|
pub rights: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account's lock (AL-1).
|
||||||
|
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Lock {
|
||||||
|
pub account_id: u32,
|
||||||
|
pub reason: String,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub locked_at: u64,
|
||||||
|
pub locked_by: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub locked_by_id: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub delegates: Vec<Delegate>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub replaced: Vec<Replaced>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lock {
|
||||||
|
pub fn delegate(&self, account_id: u32) -> Option<&Delegate> {
|
||||||
|
self.delegates.iter().find(|d| d.account_id == account_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grants a new container of this account gets: one per current
|
||||||
|
/// delegate (AL-7, containers made later).
|
||||||
|
pub fn grants_for_new(
|
||||||
|
&self,
|
||||||
|
collection: Collection,
|
||||||
|
is_trash: bool,
|
||||||
|
now: u64,
|
||||||
|
) -> Vec<(u32, Bitmap<Acl>)> {
|
||||||
|
self.delegates
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.is_current(now))
|
||||||
|
.map(|d| (d.account_id, d.access.grants(collection, is_trash)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One container's ACL as a lock change leaves it (AL-7, AL-10).
|
||||||
|
///
|
||||||
|
/// Delegates in `new` get their level's rights. The first time a delegate
|
||||||
|
/// is given a container, whatever it had there before is noted in
|
||||||
|
/// `replaced`; entries `old` already noted are carried over. Delegates only
|
||||||
|
/// in `old` get back what they had before, or nothing. Returns the new ACL
|
||||||
|
/// when it differs from `current`.
|
||||||
|
pub fn merge_grants(
|
||||||
|
current: &[AclGrant],
|
||||||
|
collection: Collection,
|
||||||
|
document_id: u32,
|
||||||
|
is_trash: bool,
|
||||||
|
old: Option<&Lock>,
|
||||||
|
new: Option<&Lock>,
|
||||||
|
now: u64,
|
||||||
|
replaced: &mut Vec<Replaced>,
|
||||||
|
) -> Option<Vec<AclGrant>> {
|
||||||
|
let mut acls = current.to_vec();
|
||||||
|
let collection_id = collection as u8;
|
||||||
|
let noted = |lock: &Lock, delegate: u32| {
|
||||||
|
lock.replaced
|
||||||
|
.iter()
|
||||||
|
.find(|r| {
|
||||||
|
r.collection == collection_id && r.document_id == document_id && r.delegate == delegate
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
|
let is_current = |lock: Option<&Lock>, delegate: u32| {
|
||||||
|
lock.and_then(|lock| lock.delegate(delegate))
|
||||||
|
.is_some_and(|d| d.is_current(now))
|
||||||
|
};
|
||||||
|
let set = |acls: &mut Vec<AclGrant>, account_id: u32, grants: Bitmap<Acl>| {
|
||||||
|
acls.retain(|a| a.account_id != account_id);
|
||||||
|
if !grants.is_empty() {
|
||||||
|
acls.push(AclGrant { account_id, grants });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delegations that ended get back what they had
|
||||||
|
if let Some(old) = old {
|
||||||
|
for delegate in &old.delegates {
|
||||||
|
if is_current(new, delegate.account_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let before = noted(old, delegate.account_id)
|
||||||
|
.map(|r| Bitmap::from(r.rights))
|
||||||
|
.unwrap_or_default();
|
||||||
|
set(&mut acls, delegate.account_id, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current delegations get their level
|
||||||
|
if let Some(new) = new {
|
||||||
|
for delegate in new.delegates.iter().filter(|d| d.is_current(now)) {
|
||||||
|
let had = old.and_then(|old| {
|
||||||
|
is_current(Some(old), delegate.account_id)
|
||||||
|
.then(|| noted(old, delegate.account_id))
|
||||||
|
.flatten()
|
||||||
|
});
|
||||||
|
match had {
|
||||||
|
Some(entry) => replaced.push(entry),
|
||||||
|
None if !is_current(old, delegate.account_id) => {
|
||||||
|
if let Some(existing) = current.iter().find(|a| a.account_id == delegate.account_id) {
|
||||||
|
replaced.push(Replaced {
|
||||||
|
collection: collection_id,
|
||||||
|
document_id,
|
||||||
|
delegate: delegate.account_id,
|
||||||
|
rights: existing.grants.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
set(
|
||||||
|
&mut acls,
|
||||||
|
delegate.account_id,
|
||||||
|
delegate.access.grants(collection, is_trash),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sorted = |acls: &[AclGrant]| {
|
||||||
|
let mut v = acls.iter().map(|a| (a.account_id, u64::from(a.grants))).collect::<Vec<_>>();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
};
|
||||||
|
(sorted(&acls) != sorted(current)).then_some(acls)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize account lock")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: serde::de::DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid account lock")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(kind: u8, parts: &[u32]) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(2 + parts.len() * 4);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(kind);
|
||||||
|
for part in parts {
|
||||||
|
key.extend_from_slice(&part.to_be_bytes());
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(kind: u8, parts: &[u32]) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(kind, parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The numbers after the kind byte, from the key's tail (the iterator may or
|
||||||
|
/// may not hand back the subspace byte).
|
||||||
|
fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option<Vec<u32>> {
|
||||||
|
let len = 2 + parts * 4;
|
||||||
|
let tail = key.get(key.len().checked_sub(len)?..)?;
|
||||||
|
(tail[0] == FEATURE && tail[1] == kind).then_some(())?;
|
||||||
|
Some(
|
||||||
|
tail[2..]
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|chunk| u32::from_be_bytes(chunk.try_into().unwrap()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account's lock, if it is locked.
|
||||||
|
pub async fn get(data: &Store, account_id: u32) -> trc::Result<Option<Lock>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Lock>>(key(KIND_LOCK, &[account_id]))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(lock)| lock))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every lock, for the console's list.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
|
||||||
|
let mut locks = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(key(KIND_LOCK, &[0]), key(KIND_LOCK, &[u32::MAX])),
|
||||||
|
|_, value| {
|
||||||
|
if let Ok(Json(lock)) = Json::<Lock>::deserialize(value) {
|
||||||
|
locks.push(lock);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(locks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accounts delegated to `delegate`, with its delegation in each.
|
||||||
|
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
|
||||||
|
let mut locked = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
key(KIND_DELEGATE, &[delegate, 0]),
|
||||||
|
key(KIND_DELEGATE, &[delegate, u32::MAX]),
|
||||||
|
)
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if let Some(parts) = parse_key(key, KIND_DELEGATE, 2) {
|
||||||
|
locked.push(parts[1]);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
let mut delegations = Vec::with_capacity(locked.len());
|
||||||
|
for account_id in locked {
|
||||||
|
if let Some(lock) = get(data, account_id).await?
|
||||||
|
&& let Some(delegation) = lock.delegate(delegate)
|
||||||
|
{
|
||||||
|
delegations.push((account_id, delegation.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(delegations)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a lock, keeping the delegate index in step with `previous`.
|
||||||
|
pub async fn set(data: &Store, lock: &Lock, previous: Option<&Lock>) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
for delegate in &previous.delegates {
|
||||||
|
if lock.delegate(delegate.account_id).is_none() {
|
||||||
|
batch.clear(class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for delegate in &lock.delegates {
|
||||||
|
batch.set(
|
||||||
|
class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batch.set(class(KIND_LOCK, &[lock.account_id]), Json(lock).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a lock and its delegate index.
|
||||||
|
pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
for delegate in &lock.delegates {
|
||||||
|
batch.clear(class(KIND_DELEGATE, &[delegate.account_id, lock.account_id]));
|
||||||
|
}
|
||||||
|
batch.clear(class(KIND_LOCK, &[lock.account_id]));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_read_back() {
|
||||||
|
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(parse_key(&any.key, KIND_DELEGATE, 2), Some(vec![7, 9]));
|
||||||
|
let mut with_subspace = vec![SUBSPACE_INBUXA];
|
||||||
|
with_subspace.extend_from_slice(&any.key);
|
||||||
|
assert_eq!(parse_key(&with_subspace, KIND_DELEGATE, 2), Some(vec![7, 9]));
|
||||||
|
assert_eq!(parse_key(&any.key, KIND_LOCK, 2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levels_grant_what_they_say() {
|
||||||
|
let read = Access::Read.grants(Collection::Mailbox, false);
|
||||||
|
assert!(read.contains(Acl::ReadItems));
|
||||||
|
assert!(!read.contains(Acl::ModifyItems), "read can't set $seen");
|
||||||
|
assert!(!read.contains(Acl::RemoveItems));
|
||||||
|
|
||||||
|
let organize = Access::Organize.grants(Collection::Mailbox, false);
|
||||||
|
assert!(organize.contains(Acl::RemoveItems), "moving needs it");
|
||||||
|
assert!(!organize.contains(Acl::Delete));
|
||||||
|
assert!(!organize.contains(Acl::Submit));
|
||||||
|
let trash = Access::Organize.grants(Collection::Mailbox, true);
|
||||||
|
assert!(!trash.contains(Acl::AddItems), "nothing moved into Trash");
|
||||||
|
let calendar = Access::Organize.grants(Collection::Calendar, false);
|
||||||
|
assert!(!calendar.contains(Acl::RemoveItems));
|
||||||
|
|
||||||
|
let full = Access::Full.grants(Collection::Mailbox, false);
|
||||||
|
assert!(full.contains(Acl::Delete) && full.contains(Acl::RemoveItems));
|
||||||
|
assert!(!full.contains(Acl::Share), "a delegate can't pass it on");
|
||||||
|
assert!(Access::Full.may_destroy() && !Access::Organize.may_destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
||||||
|
Lock {
|
||||||
|
account_id: 1,
|
||||||
|
reason: "r".into(),
|
||||||
|
locked_at: 0,
|
||||||
|
locked_by: "admin".into(),
|
||||||
|
locked_by_id: None,
|
||||||
|
delegates,
|
||||||
|
replaced,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delegate(account_id: u32, access: Access) -> Delegate {
|
||||||
|
Delegate {
|
||||||
|
account_id,
|
||||||
|
access,
|
||||||
|
send_as: false,
|
||||||
|
until: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grants_are_added_and_restored() {
|
||||||
|
let read = Access::Read.grants(Collection::Mailbox, false);
|
||||||
|
let full = Access::Full.grants(Collection::Mailbox, false);
|
||||||
|
// Delegate 2 already had a share here; delegate 3 had nothing
|
||||||
|
let earlier: Bitmap<Acl> = Bitmap::from_iter([Acl::Read]);
|
||||||
|
let current = vec![AclGrant {
|
||||||
|
account_id: 2,
|
||||||
|
grants: earlier,
|
||||||
|
}];
|
||||||
|
let lock = lock_with(
|
||||||
|
vec![delegate(2, Access::Full), delegate(3, Access::Read)],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let mut replaced = Vec::new();
|
||||||
|
let acls = merge_grants(¤t, Collection::Mailbox, 5, false, None, Some(&lock), 0, &mut replaced)
|
||||||
|
.unwrap();
|
||||||
|
assert!(acls.contains(&AclGrant { account_id: 2, grants: full }));
|
||||||
|
assert!(acls.contains(&AclGrant { account_id: 3, grants: read }));
|
||||||
|
assert_eq!(replaced.len(), 1, "only 2 had rights to put back");
|
||||||
|
assert_eq!(replaced[0].rights, u64::from(earlier));
|
||||||
|
|
||||||
|
// Running it again changes nothing and keeps the note
|
||||||
|
let locked = Lock { replaced: replaced.clone(), ..lock.clone() };
|
||||||
|
let mut again = Vec::new();
|
||||||
|
assert!(merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), Some(&locked), 0, &mut again).is_none());
|
||||||
|
assert_eq!(again, replaced);
|
||||||
|
|
||||||
|
// Unlocking puts 2's share back and removes 3
|
||||||
|
let mut none = Vec::new();
|
||||||
|
let back = merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), None, 0, &mut none).unwrap();
|
||||||
|
assert_eq!(back, vec![AclGrant { account_id: 2, grants: earlier }]);
|
||||||
|
|
||||||
|
// Ending one delegation keeps the other
|
||||||
|
let fewer = lock_with(vec![delegate(3, Access::Read)], vec![]);
|
||||||
|
let mut kept = Vec::new();
|
||||||
|
let after = merge_grants(&acls, Collection::Mailbox, 5, false, Some(&locked), Some(&fewer), 0, &mut kept).unwrap();
|
||||||
|
assert!(after.contains(&AclGrant { account_id: 2, grants: earlier }));
|
||||||
|
assert!(after.contains(&AclGrant { account_id: 3, grants: read }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_delegations_grant_nothing() {
|
||||||
|
let lock = Lock {
|
||||||
|
account_id: 1,
|
||||||
|
reason: "Left the company".into(),
|
||||||
|
locked_at: 100,
|
||||||
|
locked_by: "admin".into(),
|
||||||
|
locked_by_id: None,
|
||||||
|
delegates: vec![
|
||||||
|
Delegate {
|
||||||
|
account_id: 2,
|
||||||
|
access: Access::Read,
|
||||||
|
send_as: false,
|
||||||
|
until: Some(200),
|
||||||
|
},
|
||||||
|
Delegate {
|
||||||
|
account_id: 3,
|
||||||
|
access: Access::Full,
|
||||||
|
send_as: true,
|
||||||
|
until: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
replaced: vec![],
|
||||||
|
};
|
||||||
|
let grants = lock.grants_for_new(Collection::Mailbox, false, 300);
|
||||||
|
assert_eq!(grants.len(), 1);
|
||||||
|
assert_eq!(grants[0].0, 3);
|
||||||
|
let json = serde_json::to_string(&lock).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<Lock>(&json).unwrap(), lock);
|
||||||
|
assert!(json.contains("\"access\":\"full\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! inbuxa: a locked account's grants on its calendars, address books, file
|
||||||
|
//! folders and top-level files (audit-hold-lock spec, AL-7, AL-10). The
|
||||||
|
//! mailbox half, and the whole, are in `email::inbuxa_lock`; this half is
|
||||||
|
//! here so DAV, which sees only these, can grant on what it creates.
|
||||||
|
|
||||||
|
use crate::{cache::GroupwareCache, calendar::Calendar, contact::AddressBook, file::FileNode};
|
||||||
|
use common::{
|
||||||
|
DavResourceMetadata, Server,
|
||||||
|
auth::AccountTenantIds,
|
||||||
|
cache::invalidate::CacheInvalidationBuilder,
|
||||||
|
ipc::CacheInvalidation,
|
||||||
|
};
|
||||||
|
use inbuxa_features::lock::{self, Lock, Replaced};
|
||||||
|
use store::{
|
||||||
|
ValueKey,
|
||||||
|
write::{AlignedBytes, Archive, BatchBuilder, now},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
use types::collection::{Collection, SyncCollection};
|
||||||
|
|
||||||
|
/// The collections this half covers.
|
||||||
|
pub const DAV_COLLECTIONS: [Collection; 3] = [
|
||||||
|
Collection::Calendar,
|
||||||
|
Collection::AddressBook,
|
||||||
|
Collection::FileNode,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Who a lock's grant changes are recorded as having been made by: the
|
||||||
|
/// locked account itself, as the server acting for it.
|
||||||
|
pub async fn changed_by(server: &Server, account_id: u32) -> AccountTenantIds {
|
||||||
|
AccountTenantIds {
|
||||||
|
account_id,
|
||||||
|
tenant_id: server.account(account_id).await.ok().and_then(|a| a.id_tenant),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grants on calendars, address books, file folders and top-level files,
|
||||||
|
/// into `batch`, with what they replaced into `replaced`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn apply_dav_grants(
|
||||||
|
server: &Server,
|
||||||
|
account_id: u32,
|
||||||
|
old: Option<&Lock>,
|
||||||
|
new: Option<&Lock>,
|
||||||
|
now: u64,
|
||||||
|
replaced: &mut Vec<Replaced>,
|
||||||
|
batch: &mut BatchBuilder,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
let changed_by = changed_by(server, account_id).await;
|
||||||
|
for (sync, collection) in [
|
||||||
|
(SyncCollection::Calendar, Collection::Calendar),
|
||||||
|
(SyncCollection::AddressBook, Collection::AddressBook),
|
||||||
|
(SyncCollection::FileNode, Collection::FileNode),
|
||||||
|
] {
|
||||||
|
let resources = server
|
||||||
|
.fetch_dav_resources(account_id, account_id, sync)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for resource in &resources.resources {
|
||||||
|
// A folder covers what's in it; a file outside any folder
|
||||||
|
// needs its own grant
|
||||||
|
let top_level_file = matches!(
|
||||||
|
&resource.data,
|
||||||
|
DavResourceMetadata::File {
|
||||||
|
parent_id: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if !resource.is_container() && !top_level_file {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(current) = resource.acls() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(acls) = lock::merge_grants(
|
||||||
|
current,
|
||||||
|
collection,
|
||||||
|
resource.document_id,
|
||||||
|
false,
|
||||||
|
old,
|
||||||
|
new,
|
||||||
|
now,
|
||||||
|
replaced,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(archive) = server
|
||||||
|
.store()
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
collection,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match collection {
|
||||||
|
Collection::Calendar => {
|
||||||
|
let current = archive
|
||||||
|
.to_unarchived::<Calendar>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let mut changed = current
|
||||||
|
.deserialize::<Calendar>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
changed.acls = acls;
|
||||||
|
changed
|
||||||
|
.update(changed_by, current, account_id, resource.document_id, batch)
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
Collection::AddressBook => {
|
||||||
|
let current = archive
|
||||||
|
.to_unarchived::<AddressBook>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let mut changed = current
|
||||||
|
.deserialize::<AddressBook>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
changed.acls = acls;
|
||||||
|
changed
|
||||||
|
.update(changed_by, current, account_id, resource.document_id, batch)
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let current = archive
|
||||||
|
.to_unarchived::<FileNode>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let mut changed = current
|
||||||
|
.deserialize::<FileNode>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
changed.acls = acls;
|
||||||
|
changed
|
||||||
|
.update(
|
||||||
|
changed_by,
|
||||||
|
current,
|
||||||
|
account_id,
|
||||||
|
resource.document_id,
|
||||||
|
false,
|
||||||
|
batch,
|
||||||
|
)
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every token a lock change touches is rebuilt on its next use, on every
|
||||||
|
/// node: the locked account's and each delegate's, before and after.
|
||||||
|
pub async fn invalidate(
|
||||||
|
server: &Server,
|
||||||
|
account_id: u32,
|
||||||
|
old: Option<&Lock>,
|
||||||
|
new: Option<&Lock>,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
let mut builder = CacheInvalidationBuilder::default();
|
||||||
|
builder.invalidate(CacheInvalidation::AccessToken(account_id));
|
||||||
|
for delegate in old.into_iter().chain(new).flat_map(|l| &l.delegates) {
|
||||||
|
builder.invalidate(CacheInvalidation::AccessToken(delegate.account_id));
|
||||||
|
}
|
||||||
|
server.invalidate_caches(builder).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two lists of replaced rights say the same, in any order.
|
||||||
|
pub fn same_replaced(a: &[Replaced], b: &[Replaced]) -> bool {
|
||||||
|
let key = |r: &Replaced| (r.collection, r.document_id, r.delegate, r.rights);
|
||||||
|
let mut a = a.iter().map(key).collect::<Vec<_>>();
|
||||||
|
let mut b = b.iter().map(key).collect::<Vec<_>>();
|
||||||
|
a.sort();
|
||||||
|
b.sort();
|
||||||
|
a == b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grants the lock on `account_id`, if any, on calendars, address books and
|
||||||
|
/// files made since. For DAV, after a delegate creates one there.
|
||||||
|
pub async fn reconcile_dav(server: &Server, account_id: u32) -> trc::Result<()> {
|
||||||
|
let data = server.store();
|
||||||
|
let Some(current) = lock::get(data, account_id).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// Mailbox entries aren't this half's to change
|
||||||
|
let mut replaced = current
|
||||||
|
.replaced
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !DAV_COLLECTIONS.iter().any(|c| *c as u8 == r.collection))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
apply_dav_grants(
|
||||||
|
server,
|
||||||
|
account_id,
|
||||||
|
Some(¤t),
|
||||||
|
Some(¤t),
|
||||||
|
now(),
|
||||||
|
&mut replaced,
|
||||||
|
&mut batch,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if batch.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
server
|
||||||
|
.commit_batch(batch)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
if !same_replaced(&replaced, ¤t.replaced) {
|
||||||
|
let updated = Lock {
|
||||||
|
replaced,
|
||||||
|
..current.clone()
|
||||||
|
};
|
||||||
|
lock::set(data, &updated, Some(¤t)).await?;
|
||||||
|
}
|
||||||
|
invalidate(server, account_id, Some(¤t), Some(¤t)).await
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub mod calendar;
|
|||||||
pub mod contact;
|
pub mod contact;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod inbuxa; // inbuxa: undelete notes
|
pub mod inbuxa; // inbuxa: undelete notes
|
||||||
|
pub mod inbuxa_lock; // inbuxa: account lock grants
|
||||||
pub mod scheduling;
|
pub mod scheduling;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -239,10 +239,20 @@ impl TokenHandler for Server {
|
|||||||
.validate_access_token(GrantType::RefreshToken.into(), refresh_token)
|
.validate_access_token(GrantType::RefreshToken.into(), refresh_token)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
// inbuxa: AL-2: a locked account gets no new tokens
|
||||||
|
Ok(token_info)
|
||||||
|
if self
|
||||||
|
.access_token(token_info.account_id)
|
||||||
|
.await
|
||||||
|
.is_ok_and(|token| token.is_locked()) =>
|
||||||
|
{
|
||||||
|
TokenResponse::error(ErrorType::InvalidGrant)
|
||||||
|
}
|
||||||
Ok(token_info) => self
|
Ok(token_info) => self
|
||||||
.issue_token(
|
.issue_token(
|
||||||
token_info.account_id,
|
token_info.account_id,
|
||||||
"",
|
// inbuxa: AU-5: the client travels in the refresh token
|
||||||
|
token_info.claims.as_deref().unwrap_or_default(),
|
||||||
issuer,
|
issuer,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -342,7 +352,8 @@ impl TokenHandler for Server {
|
|||||||
account_id,
|
account_id,
|
||||||
account_name,
|
account_name,
|
||||||
self.core.oauth.oauth_expiry_refresh_token,
|
self.core.oauth.oauth_expiry_refresh_token,
|
||||||
None,
|
// inbuxa: AU-5: so a refreshed access token still names it
|
||||||
|
Some(client_id),
|
||||||
credential_version.into(),
|
credential_version.into(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use ahash::AHashMap;
|
use ahash::AHashMap;
|
||||||
@@ -198,6 +200,13 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
.access_token(self.account_id)
|
.access_token(self.account_id)
|
||||||
.await
|
.await
|
||||||
.and_then(|inner| {
|
.and_then(|inner| {
|
||||||
|
// inbuxa: AL-3: a session opened before its account was
|
||||||
|
// locked is refused from its next command
|
||||||
|
if inner.is_locked() {
|
||||||
|
return Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.details("Account is locked"));
|
||||||
|
}
|
||||||
AccessToken::renew(inner, self.access_token.credential_id(), self.remote_addr)
|
AccessToken::renew(inner, self.access_token.credential_id(), self.remote_addr)
|
||||||
})
|
})
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -141,6 +143,14 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
.await
|
.await
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: AL-7: a folder a delegate makes in a locked account gets
|
||||||
|
// the lock's grants, so the delegate can see it
|
||||||
|
if params.account_id != self.account_id
|
||||||
|
&& let Err(err) = email::inbuxa_lock::reconcile(&self.server, params.account_id).await
|
||||||
|
{
|
||||||
|
trc::error!(err.details("Failed to grant a lock's delegates on a new folder"));
|
||||||
|
}
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Imap(trc::ImapEvent::CreateMailbox),
|
Imap(trc::ImapEvent::CreateMailbox),
|
||||||
SpanId = self.session_id,
|
SpanId = self.session_id,
|
||||||
|
|||||||
@@ -45,14 +45,22 @@ impl<T: SessionStream> Session<T> {
|
|||||||
let (data, mailbox) = self.state.select_data();
|
let (data, mailbox) = self.state.select_data();
|
||||||
|
|
||||||
// Validate ACL
|
// Validate ACL
|
||||||
if !data
|
// inbuxa: AL-6: a delegate below full may move mail, never delete it
|
||||||
.check_mailbox_acl(
|
let may_destroy = data
|
||||||
mailbox.id.account_id,
|
.refresh_access_token()
|
||||||
mailbox.id.mailbox_id,
|
|
||||||
Acl::RemoveItems,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.imap_ctx(&request.tag, trc::location!())?
|
.imap_ctx(&request.tag, trc::location!())?
|
||||||
|
.delegation(mailbox.id.account_id)
|
||||||
|
.is_none_or(|delegation| delegation.access.may_destroy());
|
||||||
|
if !may_destroy
|
||||||
|
|| !data
|
||||||
|
.check_mailbox_acl(
|
||||||
|
mailbox.id.account_id,
|
||||||
|
mailbox.id.mailbox_id,
|
||||||
|
Acl::RemoveItems,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.imap_ctx(&request.tag, trc::location!())?
|
||||||
{
|
{
|
||||||
return Err(trc::ImapEvent::Error
|
return Err(trc::ImapEvent::Error
|
||||||
.into_err()
|
.into_err()
|
||||||
@@ -143,6 +151,16 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
) -> trc::Result<Option<u32>> {
|
) -> trc::Result<Option<u32>> {
|
||||||
// Obtain message ids
|
// Obtain message ids
|
||||||
let account_id = mailbox.id.account_id;
|
let account_id = mailbox.id.account_id;
|
||||||
|
// inbuxa: AL-6: nothing is deleted for a delegate below full (CLOSE
|
||||||
|
// expunges quietly, so it deletes nothing, quietly)
|
||||||
|
if self
|
||||||
|
.refresh_access_token()
|
||||||
|
.await?
|
||||||
|
.delegation(account_id)
|
||||||
|
.is_some_and(|delegation| !delegation.access.may_destroy())
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let mut deleted_ids = RoaringBitmap::from_iter(
|
let mut deleted_ids = RoaringBitmap::from_iter(
|
||||||
self.server
|
self.server
|
||||||
.get_cached_messages(account_id)
|
.get_cached_messages(account_id)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:AccountLock/get` and `/set` under `urn:inbuxa:jmap`: an account
|
||||||
|
//! locked, and the people it is handed to (audit-hold-lock spec, AL-1 to
|
||||||
|
//! AL-12). A lock's id is the locked account's id. Creating one locks the
|
||||||
|
//! account, updating changes its delegates, destroying unlocks it. The set
|
||||||
|
//! call's `reason` argument says why, for the audit log (AU-12); creating
|
||||||
|
//! takes it as a property.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
object::{AnyId, JmapObject, JmapObjectId},
|
||||||
|
request::deserialize::DeserializeArguments,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AccountLock;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum AccountLockProperty {
|
||||||
|
Id,
|
||||||
|
/// The locked account (on create; afterwards the same as `id`).
|
||||||
|
AccountId,
|
||||||
|
Name,
|
||||||
|
Reason,
|
||||||
|
LockedAt,
|
||||||
|
LockedBy,
|
||||||
|
Delegates,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum AccountLockValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for AccountLockProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside a delegate stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => AccountLockProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
AccountLockProperty::Id => "id",
|
||||||
|
AccountLockProperty::AccountId => "accountId",
|
||||||
|
AccountLockProperty::Name => "name",
|
||||||
|
AccountLockProperty::Reason => "reason",
|
||||||
|
AccountLockProperty::LockedAt => "lockedAt",
|
||||||
|
AccountLockProperty::LockedBy => "lockedBy",
|
||||||
|
AccountLockProperty::Delegates => "delegates",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountLockProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => AccountLockProperty::Id,
|
||||||
|
b"accountId" => AccountLockProperty::AccountId,
|
||||||
|
b"name" => AccountLockProperty::Name,
|
||||||
|
b"reason" => AccountLockProperty::Reason,
|
||||||
|
b"lockedAt" => AccountLockProperty::LockedAt,
|
||||||
|
b"lockedBy" => AccountLockProperty::LockedBy,
|
||||||
|
b"delegates" => AccountLockProperty::Delegates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AccountLockProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
AccountLockProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for AccountLockValue {
|
||||||
|
type Property = AccountLockProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(AccountLockProperty::Id | AccountLockProperty::AccountId) => {
|
||||||
|
Id::from_str(value).ok().map(AccountLockValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
AccountLockValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own arguments: why (AU-12).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AccountLockSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for AccountLockSetArguments {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
if key == "reason" {
|
||||||
|
self.reason = map.next_value()?;
|
||||||
|
} else {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for AccountLock {
|
||||||
|
type Property = AccountLockProperty;
|
||||||
|
|
||||||
|
type Element = AccountLockValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = AccountLockSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = AccountLockProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for AccountLockValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
AccountLockValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for AccountLockValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
AccountLockValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
AccountLockValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = AccountLockValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for AccountLockProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub mod contact;
|
|||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod email_submission;
|
pub mod email_submission;
|
||||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||||
|
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::AuditSettings(response) => {
|
GetResponseMethod::AuditSettings(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::AccountLock(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::ProtocolPolicy(response) => {
|
GetResponseMethod::ProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
@@ -107,6 +108,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::AuditVerification(request) => {
|
SetRequestMethod::AuditVerification(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::AccountLock(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::ProtocolPolicy(request) => {
|
SetRequestMethod::ProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,9 +133,31 @@ pub enum Capabilities {
|
|||||||
FileNode(FileNodeCapabilities),
|
FileNode(FileNodeCapabilities),
|
||||||
WebPush(WebPushCapabilities),
|
WebPush(WebPushCapabilities),
|
||||||
Inbuxa(InbuxaAccountCapabilities),
|
Inbuxa(InbuxaAccountCapabilities),
|
||||||
|
// inbuxa: AL-7
|
||||||
|
InbuxaDelegated(InbuxaDelegatedCapabilities),
|
||||||
Empty(EmptyCapabilities),
|
Empty(EmptyCapabilities),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: `urn:inbuxa:jmap` on a locked account delegated to the signed-in
|
||||||
|
/// principal (audit-hold-lock spec, AL-7), so a client can tell it from an
|
||||||
|
/// ordinary share without guessing from `isReadOnly`.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct InbuxaDelegatedCapabilities {
|
||||||
|
pub delegation: DelegationInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct DelegationInfo {
|
||||||
|
/// Always true: only locked accounts are delegated.
|
||||||
|
pub locked: bool,
|
||||||
|
/// `read`, `organize` or `full`.
|
||||||
|
pub access: &'static str,
|
||||||
|
#[serde(rename(serialize = "sendAs"))]
|
||||||
|
pub send_as: bool,
|
||||||
|
/// When the delegation ends, if it does (UTC).
|
||||||
|
pub until: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account.
|
/// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account.
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct InbuxaAccountCapabilities {
|
pub struct InbuxaAccountCapabilities {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ pub enum MethodObject {
|
|||||||
AuditSettings,
|
AuditSettings,
|
||||||
AuditExport,
|
AuditExport,
|
||||||
AuditVerification,
|
AuditVerification,
|
||||||
|
// inbuxa: account lock with delegation
|
||||||
|
AccountLock,
|
||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,8 @@ impl MethodObject {
|
|||||||
MethodObject::AuditEvent
|
MethodObject::AuditEvent
|
||||||
| MethodObject::AuditSettings
|
| MethodObject::AuditSettings
|
||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification => Capability::Inbuxa,
|
| MethodObject::AuditVerification
|
||||||
|
| MethodObject::AccountLock => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
@@ -274,6 +277,8 @@ impl MethodName {
|
|||||||
(MethodFunction::Get, MethodObject::AuditSettings) => "inbuxa:AuditSettings/get",
|
(MethodFunction::Get, MethodObject::AuditSettings) => "inbuxa:AuditSettings/get",
|
||||||
(MethodFunction::Set, MethodObject::AuditSettings) => "inbuxa:AuditSettings/set",
|
(MethodFunction::Set, MethodObject::AuditSettings) => "inbuxa:AuditSettings/set",
|
||||||
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
|
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
|
||||||
|
(MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get",
|
||||||
|
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||||
"inbuxa:AuditVerification/set"
|
"inbuxa:AuditVerification/set"
|
||||||
}
|
}
|
||||||
@@ -416,6 +421,8 @@ impl MethodName {
|
|||||||
"inbuxa:AuditSettings/get" => (MethodObject::AuditSettings, MethodFunction::Get),
|
"inbuxa:AuditSettings/get" => (MethodObject::AuditSettings, MethodFunction::Get),
|
||||||
"inbuxa:AuditSettings/set" => (MethodObject::AuditSettings, MethodFunction::Set),
|
"inbuxa:AuditSettings/set" => (MethodObject::AuditSettings, MethodFunction::Set),
|
||||||
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
|
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
|
||||||
|
"inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get),
|
||||||
|
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
@@ -479,6 +486,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::AuditSettings => "inbuxa:AuditSettings",
|
MethodObject::AuditSettings => "inbuxa:AuditSettings",
|
||||||
MethodObject::AuditExport => "inbuxa:AuditExport",
|
MethodObject::AuditExport => "inbuxa:AuditExport",
|
||||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||||
|
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ pub enum GetRequestMethod {
|
|||||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
|
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
@@ -149,6 +150,7 @@ pub enum SetRequestMethod<'x> {
|
|||||||
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
|
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
|
|||||||
@@ -551,6 +551,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// inbuxa: account lock with delegation
|
||||||
|
(MethodFunction::Get, MethodObject::AccountLock) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AccountLock(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::AccountLock) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AccountLock(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
// inbuxa: the audit log
|
// inbuxa: the audit log
|
||||||
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ pub enum GetResponseMethod {
|
|||||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||||
|
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
@@ -136,6 +137,7 @@ pub enum SetResponseMethod {
|
|||||||
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
|
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
@@ -750,3 +752,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_audit::AuditVerification>> for R
|
|||||||
ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value)))
|
ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: account lock with delegation
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_account_lock::AccountLock>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_account_lock::AccountLock>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::AccountLock(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_account_lock::AccountLock>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use types::{collection::Collection, id::Id};
|
|||||||
|
|
||||||
pub trait JmapAuthorization {
|
pub trait JmapAuthorization {
|
||||||
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self>;
|
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self>;
|
||||||
|
/// inbuxa: AL-8: the account's own, or a delegate allowed to send as it.
|
||||||
|
fn assert_can_send(&self, account_id: Id) -> trc::Result<&Self>;
|
||||||
fn assert_has_jmap_permission(
|
fn assert_has_jmap_permission(
|
||||||
&self,
|
&self,
|
||||||
request: &RequestMethod,
|
request: &RequestMethod,
|
||||||
@@ -31,6 +33,17 @@ pub trait JmapAuthorization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl JmapAuthorization for AccessToken {
|
impl JmapAuthorization for AccessToken {
|
||||||
|
fn assert_can_send(&self, account_id: Id) -> trc::Result<&Self> {
|
||||||
|
if self
|
||||||
|
.delegation(account_id.document_id())
|
||||||
|
.is_some_and(|delegation| delegation.send_as)
|
||||||
|
{
|
||||||
|
Ok(self)
|
||||||
|
} else {
|
||||||
|
self.assert_is_member(account_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> {
|
fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> {
|
||||||
if self.is_member(account_id.document_id()) {
|
if self.is_member(account_id.document_id()) {
|
||||||
Ok(self)
|
Ok(self)
|
||||||
@@ -81,6 +94,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
|
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
|
||||||
Permission::SysAuditGet
|
Permission::SysAuditGet
|
||||||
}
|
}
|
||||||
|
// inbuxa: account lock (AL-12)
|
||||||
|
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||||
// inbuxa: legacy protocols off. It takes listeners away and
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
@@ -199,6 +214,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
),
|
),
|
||||||
|
// inbuxa: account lock (AL-12)
|
||||||
|
SetRequestMethod::AccountLock(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysAccountLockCreate,
|
||||||
|
Permission::SysAccountLockUpdate,
|
||||||
|
Permission::SysAccountLockDestroy,
|
||||||
|
),
|
||||||
SetRequestMethod::AuditVerification(s) => validate_set(
|
SetRequestMethod::AuditVerification(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -345,6 +368,7 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::AuditSettings
|
| MethodObject::AuditSettings
|
||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
|
| MethodObject::AccountLock
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
|
|||||||
@@ -143,15 +143,27 @@ impl RequestHandler for Server {
|
|||||||
| RequestMethod::Changes(_)
|
| RequestMethod::Changes(_)
|
||||||
| RequestMethod::QueryChanges(_)
|
| RequestMethod::QueryChanges(_)
|
||||||
);
|
);
|
||||||
if matches!(
|
let is_write = matches!(
|
||||||
call.method,
|
call.method,
|
||||||
RequestMethod::Set(_)
|
RequestMethod::Set(_)
|
||||||
| RequestMethod::Copy(_)
|
| RequestMethod::Copy(_)
|
||||||
| RequestMethod::ImportEmail(_)
|
| RequestMethod::ImportEmail(_)
|
||||||
| RequestMethod::UploadBlob(_)
|
| RequestMethod::UploadBlob(_)
|
||||||
) {
|
);
|
||||||
|
if is_write {
|
||||||
has_written = true;
|
has_written = true;
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-7: what a delegate makes in a locked account
|
||||||
|
// may need the lock's grants
|
||||||
|
let makes_containers = is_write
|
||||||
|
&& matches!(
|
||||||
|
call.name.obj,
|
||||||
|
MethodObject::Mailbox
|
||||||
|
| MethodObject::Calendar
|
||||||
|
| MethodObject::AddressBook
|
||||||
|
| MethodObject::FileNode
|
||||||
|
);
|
||||||
|
let call_name = call.name.as_str().into_owned();
|
||||||
let presented = match &call.method {
|
let presented = match &call.method {
|
||||||
RequestMethod::Changes(changes) => match &changes.since_state {
|
RequestMethod::Changes(changes) => match &changes.since_state {
|
||||||
jmap_proto::types::state::State::Exact(change_id) => {
|
jmap_proto::types::state::State::Exact(change_id) => {
|
||||||
@@ -189,7 +201,28 @@ impl RequestHandler for Server {
|
|||||||
};
|
};
|
||||||
let (result, reached) = result;
|
let (result, reached) = result;
|
||||||
for account_id in reached {
|
for account_id in reached {
|
||||||
self.audit_foreign_access(access_token, account_id, false).await;
|
// inbuxa: AL-9: a delegate's access, and what it
|
||||||
|
// changes, are recorded; anyone else here impersonated
|
||||||
|
if let Some(delegation) = access_token.delegation(account_id) {
|
||||||
|
let access = delegation.access.as_str();
|
||||||
|
self.audit_delegate(
|
||||||
|
access_token,
|
||||||
|
account_id,
|
||||||
|
access,
|
||||||
|
is_write.then_some(call_name.as_str()),
|
||||||
|
result.as_ref().err(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if makes_containers
|
||||||
|
&& result.is_ok()
|
||||||
|
&& let Err(err) =
|
||||||
|
email::inbuxa_lock::reconcile(self, account_id).await
|
||||||
|
{
|
||||||
|
trc::error!(err.details("Failed to grant a lock's delegates on new folders"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.audit_foreign_access(access_token, account_id, false).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match result
|
match result
|
||||||
{
|
{
|
||||||
@@ -237,6 +270,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::AuditVerification(set_response) => {
|
SetResponseMethod::AuditVerification(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::AccountLock(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::Explanation(set_response) => {
|
SetResponseMethod::Explanation(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -354,13 +390,15 @@ impl RequestHandler for Server {
|
|||||||
}
|
}
|
||||||
GetRequestMethod::Identity(mut req) => {
|
GetRequestMethod::Identity(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_is_member(req.account_id)?;
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.identity_get(*req).await?.into()
|
self.identity_get(*req).await?.into()
|
||||||
}
|
}
|
||||||
GetRequestMethod::EmailSubmission(mut req) => {
|
GetRequestMethod::EmailSubmission(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_is_member(req.account_id)?;
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.email_submission_get(*req).await?.into()
|
self.email_submission_get(*req).await?.into()
|
||||||
}
|
}
|
||||||
@@ -401,6 +439,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: account lock with delegation (AL-1)
|
||||||
|
GetRequestMethod::AccountLock(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::account_lock::get(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
// inbuxa: the audit log (AU-9)
|
// inbuxa: the audit log (AU-9)
|
||||||
GetRequestMethod::AuditEvent(mut req) => {
|
GetRequestMethod::AuditEvent(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -526,7 +571,8 @@ impl RequestHandler for Server {
|
|||||||
}
|
}
|
||||||
QueryRequestMethod::EmailSubmission(mut req) => {
|
QueryRequestMethod::EmailSubmission(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_is_member(req.account_id)?;
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.email_submission_query(*req).await?.into()
|
self.email_submission_query(*req).await?.into()
|
||||||
}
|
}
|
||||||
@@ -631,7 +677,8 @@ impl RequestHandler for Server {
|
|||||||
}
|
}
|
||||||
SetRequestMethod::EmailSubmission(mut req) => {
|
SetRequestMethod::EmailSubmission(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_is_member(req.account_id)?;
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.email_submission_set(*req, &session.instance, next_call)
|
self.email_submission_set(*req, &session.instance, next_call)
|
||||||
.await?
|
.await?
|
||||||
@@ -665,6 +712,7 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(crate::inbuxa::fastmail::set(self, access_token, req)),
|
|req| Box::pin(crate::inbuxa::fastmail::set(self, access_token, req)),
|
||||||
)
|
)
|
||||||
@@ -681,6 +729,7 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(crate::inbuxa::deleted_account::set(self, access_token, req)),
|
|req| Box::pin(crate::inbuxa::deleted_account::set(self, access_token, req)),
|
||||||
)
|
)
|
||||||
@@ -697,6 +746,7 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(crate::inbuxa::ai_limits::set(self, access_token, req)),
|
|req| Box::pin(crate::inbuxa::ai_limits::set(self, access_token, req)),
|
||||||
)
|
)
|
||||||
@@ -712,12 +762,41 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(crate::inbuxa::audit_log::settings_set(self, access_token, req)),
|
|req| Box::pin(crate::inbuxa::audit_log::settings_set(self, access_token, req)),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: account lock with delegation, recorded with its
|
||||||
|
// reason (AL-1, AU-12)
|
||||||
|
SetRequestMethod::AccountLock(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone().or_else(|| {
|
||||||
|
req.create.as_ref().and_then(|create| {
|
||||||
|
create.values().find_map(|value| {
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.ok()?
|
||||||
|
.get("reason")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::account_lock::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AuditExport(mut req) => {
|
SetRequestMethod::AuditExport(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||||
@@ -747,6 +826,7 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(crate::inbuxa::protocol_policy::set(self, access_token, req)),
|
|req| Box::pin(crate::inbuxa::protocol_policy::set(self, access_token, req)),
|
||||||
)
|
)
|
||||||
@@ -763,6 +843,7 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(crate::inbuxa::tenant_protocol_policy::set(self, access_token, req)),
|
|req| Box::pin(crate::inbuxa::tenant_protocol_policy::set(self, access_token, req)),
|
||||||
)
|
)
|
||||||
@@ -840,6 +921,7 @@ impl RequestHandler for Server {
|
|||||||
session,
|
session,
|
||||||
&method_name.obj.to_string(),
|
&method_name.obj.to_string(),
|
||||||
Some(object_type),
|
Some(object_type),
|
||||||
|
None,
|
||||||
*req,
|
*req,
|
||||||
|req| Box::pin(self.registry_set(object_type, req, access_token, session)),
|
|req| Box::pin(self.registry_set(object_type, req, access_token, session)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use common::{Server, auth::AccessToken};
|
use common::{Server, auth::AccessToken};
|
||||||
use jmap_proto::request::capability::{
|
use jmap_proto::request::capability::{
|
||||||
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, Session,
|
Account, Capabilities, Capability, EmptyCapabilities, InbuxaAccountCapabilities, InbuxaDelegatedCapabilities, DelegationInfo, Session,
|
||||||
};
|
};
|
||||||
use registry::schema::enums::Permission;
|
use registry::schema::enums::Permission;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -116,11 +116,16 @@ impl SessionHandler for Server {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: AL-6, AL-7: a delegated locked account says so, and is
|
||||||
|
// read-only at the read level
|
||||||
|
let delegation = access_token.delegation(account_id).cloned();
|
||||||
let account_id = Id::from(account_id);
|
let account_id = Id::from(account_id);
|
||||||
let mut account = Account {
|
let mut account = Account {
|
||||||
name: account.name().to_string(),
|
name: account.name().to_string(),
|
||||||
is_personal: false,
|
is_personal: false,
|
||||||
is_read_only: false,
|
is_read_only: delegation
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|d| d.access == inbuxa_features::lock::Access::Read),
|
||||||
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
|
account_capabilities: VecMap::with_capacity(account_capabilities.len()),
|
||||||
};
|
};
|
||||||
for capability in access_token.account_capabilities() {
|
for capability in access_token.account_capabilities() {
|
||||||
@@ -132,6 +137,22 @@ impl SessionHandler for Server {
|
|||||||
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
|
.unwrap_or_else(|| Capabilities::Empty(EmptyCapabilities::default())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(delegation) = delegation {
|
||||||
|
account.account_capabilities.append(
|
||||||
|
Capability::Inbuxa,
|
||||||
|
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
||||||
|
delegation: DelegationInfo {
|
||||||
|
locked: true,
|
||||||
|
access: delegation.access.as_str(),
|
||||||
|
send_as: delegation.send_as,
|
||||||
|
until: delegation.until.map(|until| {
|
||||||
|
jmap_proto::types::date::UTCDate::from_timestamp(until as i64)
|
||||||
|
.to_string()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
session.accounts.append(account_id, account);
|
session.accounts.append(account_id, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::AuditSettings
|
| MethodObject::AuditSettings
|
||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
|
| MethodObject::AccountLock
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -1141,7 +1143,20 @@ impl EmailSet for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process deletions
|
// Process deletions
|
||||||
if !will_destroy.is_empty() {
|
// inbuxa: AL-6: a delegate below full may move mail, never delete it
|
||||||
|
if !will_destroy.is_empty()
|
||||||
|
&& access_token
|
||||||
|
.delegation(account_id)
|
||||||
|
.is_some_and(|delegation| !delegation.access.may_destroy())
|
||||||
|
{
|
||||||
|
for destroy_id in will_destroy {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
destroy_id,
|
||||||
|
SetError::forbidden()
|
||||||
|
.with_description("A delegate at this level can move mail but not delete it."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if !will_destroy.is_empty() {
|
||||||
let email_ids = cache.email_document_ids();
|
let email_ids = cache.email_document_ids();
|
||||||
let can_destroy_message_ids = if access_token.is_shared(account_id) {
|
let can_destroy_message_ids = if access_token.is_shared(account_id) {
|
||||||
cache.shared_messages(access_token, Acl::RemoveItems).into()
|
cache.shared_messages(access_token, Acl::RemoveItems).into()
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:AccountLock` (audit-hold-lock spec, AL-1 to AL-12): locking an
|
||||||
|
//! account, handing it to delegates, and unlocking it. The grants
|
||||||
|
//! themselves are `email::inbuxa_lock`'s.
|
||||||
|
|
||||||
|
use common::{
|
||||||
|
Server,
|
||||||
|
auth::AccessToken,
|
||||||
|
ipc::{BroadcastEvent, PushEvent},
|
||||||
|
};
|
||||||
|
use email::inbuxa_lock::apply_grants;
|
||||||
|
use groupware::inbuxa_lock::invalidate;
|
||||||
|
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_account_lock::{
|
||||||
|
AccountLock, AccountLockProperty as P, AccountLockSetArguments, AccountLockValue,
|
||||||
|
},
|
||||||
|
request::IntoValid,
|
||||||
|
types::date::UTCDate,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use store::write::now;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type LValue = Value<'static, P, AccountLockValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[
|
||||||
|
P::Id,
|
||||||
|
P::AccountId,
|
||||||
|
P::Name,
|
||||||
|
P::Reason,
|
||||||
|
P::LockedAt,
|
||||||
|
P::LockedBy,
|
||||||
|
P::Delegates,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether the caller may lock, change or unlock `account_id` (AL-12): an
|
||||||
|
/// administrator for an account in reach, never its own, never a group.
|
||||||
|
async fn assert_reach(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
account_id: u32,
|
||||||
|
) -> Result<(), SetError<P>> {
|
||||||
|
if access_token.is_account_id(account_id) {
|
||||||
|
return Err(SetError::forbidden().with_description("You can't lock your own account."));
|
||||||
|
}
|
||||||
|
let Ok(account) = server.account(account_id).await else {
|
||||||
|
return Err(SetError::not_found());
|
||||||
|
};
|
||||||
|
if !account.is_user_account() {
|
||||||
|
return Err(SetError::invalid_properties()
|
||||||
|
.with_property(P::AccountId)
|
||||||
|
.with_description("Only a person's account can be locked."));
|
||||||
|
}
|
||||||
|
match access_token.tenant_id() {
|
||||||
|
// A tenant administrator reaches its own tenant's accounts only
|
||||||
|
Some(tenant_id) if account.id_tenant != Some(tenant_id) => Err(SetError::not_found()),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and checks the delegates asked for (AL-5, AL-6, AL-8).
|
||||||
|
async fn parse_delegates(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
locked_id: u32,
|
||||||
|
value: LValue,
|
||||||
|
) -> Result<Vec<Delegate>, SetError<P>> {
|
||||||
|
let invalid = |why: String| {
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(P::Delegates)
|
||||||
|
.with_description(why)
|
||||||
|
};
|
||||||
|
let json: serde_json::Value = value.into();
|
||||||
|
let Some(items) = json.as_array() else {
|
||||||
|
return Err(invalid("delegates must be a list.".into()));
|
||||||
|
};
|
||||||
|
if items.len() > MAX_DELEGATES {
|
||||||
|
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
|
||||||
|
}
|
||||||
|
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
||||||
|
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
let account_id = item["accountId"]
|
||||||
|
.as_str()
|
||||||
|
.and_then(|id| Id::from_str(id).ok())
|
||||||
|
.map(|id| id.document_id())
|
||||||
|
.ok_or_else(|| invalid("Each delegate needs an accountId.".into()))?;
|
||||||
|
let access = item["access"]
|
||||||
|
.as_str()
|
||||||
|
.and_then(Access::parse)
|
||||||
|
.ok_or_else(|| invalid("access must be read, organize or full.".into()))?;
|
||||||
|
let send_as = item["sendAs"].as_bool().unwrap_or(false);
|
||||||
|
let until = match item.get("until").filter(|v| !v.is_null()) {
|
||||||
|
None => None,
|
||||||
|
Some(value) => Some(
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|d| UTCDate::from_str(d).ok())
|
||||||
|
.map(|d| d.timestamp().max(0) as u64)
|
||||||
|
.ok_or_else(|| invalid("until must be a UTC date.".into()))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if account_id == locked_id {
|
||||||
|
return Err(invalid("An account can't be its own delegate.".into()));
|
||||||
|
}
|
||||||
|
if access_token.is_account_id(account_id) && access_token.tenant_id().is_some() {
|
||||||
|
return Err(invalid(
|
||||||
|
"Only a server administrator may make themselves a delegate.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if send_as && access == Access::Read {
|
||||||
|
return Err(invalid(
|
||||||
|
"Sending as the account needs organize or full access: the message is made in its Drafts first."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Ok(delegate) = server.account(account_id).await else {
|
||||||
|
return Err(invalid(format!("No account {}.", Id::from(account_id))));
|
||||||
|
};
|
||||||
|
if !delegate.is_user_account() {
|
||||||
|
return Err(invalid("A delegate must be a person, not a group.".into()));
|
||||||
|
}
|
||||||
|
// Delegates stay in the locked account's tenant, unless a server
|
||||||
|
// administrator says otherwise (AL-5)
|
||||||
|
if access_token.tenant_id().is_some() && delegate.id_tenant != locked_tenant {
|
||||||
|
return Err(invalid("A delegate must be in the same organization.".into()));
|
||||||
|
}
|
||||||
|
if delegates.iter().any(|d| d.account_id == account_id) {
|
||||||
|
return Err(invalid("A delegate is listed twice.".into()));
|
||||||
|
}
|
||||||
|
delegates.push(Delegate {
|
||||||
|
account_id,
|
||||||
|
access,
|
||||||
|
send_as,
|
||||||
|
until,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(delegates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the account's open sessions, here and on every node (AL-3).
|
||||||
|
async fn end_sessions(server: &Server, account_id: u32) {
|
||||||
|
let _ = server
|
||||||
|
.inner
|
||||||
|
.ipc
|
||||||
|
.push_tx
|
||||||
|
.send(PushEvent::Revoke { account_id })
|
||||||
|
.await;
|
||||||
|
server
|
||||||
|
.cluster_broadcast(BroadcastEvent::EndSessions(account_id))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date(seconds: u64) -> LValue {
|
||||||
|
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
||||||
|
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
|
||||||
|
P::Reason => Value::Str(lock.reason.clone().into()),
|
||||||
|
P::LockedAt => date(lock.locked_at),
|
||||||
|
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
||||||
|
P::Delegates => {
|
||||||
|
let mut items = Vec::with_capacity(lock.delegates.len());
|
||||||
|
for delegate in &lock.delegates {
|
||||||
|
let mut item = Map::with_capacity(5);
|
||||||
|
item.insert_unchecked(
|
||||||
|
Key::Borrowed("accountId"),
|
||||||
|
Value::Str(Id::from(delegate.account_id).to_string().into()),
|
||||||
|
);
|
||||||
|
item.insert_unchecked(
|
||||||
|
Key::Borrowed("name"),
|
||||||
|
Value::Str(server.audit_account_name(delegate.account_id).await.into()),
|
||||||
|
);
|
||||||
|
item.insert_unchecked(
|
||||||
|
Key::Borrowed("access"),
|
||||||
|
Value::Str(Cow::Borrowed(delegate.access.as_str())),
|
||||||
|
);
|
||||||
|
item.insert_unchecked(Key::Borrowed("sendAs"), Value::Bool(delegate.send_as));
|
||||||
|
item.insert_unchecked(
|
||||||
|
Key::Borrowed("until"),
|
||||||
|
delegate.until.map_or(Value::Null, date),
|
||||||
|
);
|
||||||
|
items.push(Value::Object(item));
|
||||||
|
}
|
||||||
|
Value::Array(items)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a lock is in the caller's reach: every lock at server level, the
|
||||||
|
/// tenant's own inside one.
|
||||||
|
async fn in_reach(server: &Server, access_token: &AccessToken, account_id: u32) -> bool {
|
||||||
|
match access_token.tenant_id() {
|
||||||
|
None => true,
|
||||||
|
Some(tenant_id) => server
|
||||||
|
.account(account_id)
|
||||||
|
.await
|
||||||
|
.is_ok_and(|a| a.id_tenant == Some(tenant_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:AccountLock/get`: the locks in reach.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<AccountLock>,
|
||||||
|
) -> trc::Result<GetResponse<AccountLock>> {
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let data = server.store();
|
||||||
|
match ids {
|
||||||
|
None => {
|
||||||
|
for current in lock::all(data).await? {
|
||||||
|
if in_reach(server, access_token, current.account_id).await {
|
||||||
|
response.list.push(to_value(server, ¤t, &properties).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
match lock::get(data, id.document_id()).await? {
|
||||||
|
Some(current) if in_reach(server, access_token, current.account_id).await => {
|
||||||
|
response.list.push(to_value(server, ¤t, &properties).await);
|
||||||
|
}
|
||||||
|
_ => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_of(reason: Option<&str>) -> Option<String> {
|
||||||
|
reason
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|r| !r.is_empty())
|
||||||
|
.map(|r| r.chars().take(500).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_required() -> SetError<P> {
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(P::Reason)
|
||||||
|
.with_description("Say why: a reason is required and is kept in the audit log.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:AccountLock/set`: create locks, update changes delegates or the
|
||||||
|
/// reason, destroy unlocks. The request layer records each.
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, AccountLock>,
|
||||||
|
) -> trc::Result<SetResponse<AccountLock>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
let arguments: AccountLockSetArguments = std::mem::take(&mut request.arguments);
|
||||||
|
let data = server.store();
|
||||||
|
let actor = server.audit_actor(access_token).await;
|
||||||
|
|
||||||
|
for (client_id, value) in request.unwrap_create() {
|
||||||
|
let mut account_id = None;
|
||||||
|
let mut reason = None;
|
||||||
|
let mut delegates_value = None;
|
||||||
|
let mut invalid = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
match (&key, value) {
|
||||||
|
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
||||||
|
account_id = Some(id.document_id())
|
||||||
|
}
|
||||||
|
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
||||||
|
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
||||||
|
_ => {
|
||||||
|
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(error) = invalid {
|
||||||
|
response.not_created.append(client_id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(account_id) = account_id else {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::invalid_properties().with_property(P::AccountId),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
|
||||||
|
response.not_created.append(client_id, reason_required());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
|
response.not_created.append(client_id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if lock::get(data, account_id).await?.is_some() {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::already_exists().with_description("That account is already locked."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let delegates = match delegates_value {
|
||||||
|
Some(value) => match parse_delegates(server, access_token, account_id, value).await {
|
||||||
|
Ok(delegates) => delegates,
|
||||||
|
Err(error) => {
|
||||||
|
response.not_created.append(client_id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
let mut created = Lock {
|
||||||
|
account_id,
|
||||||
|
reason,
|
||||||
|
locked_at: now(),
|
||||||
|
locked_by: actor.name.clone(),
|
||||||
|
locked_by_id: actor.account_id,
|
||||||
|
delegates,
|
||||||
|
replaced: Vec::new(),
|
||||||
|
};
|
||||||
|
// The lock is written first: from here the account can't sign in,
|
||||||
|
// whatever happens to the grants
|
||||||
|
lock::set(data, &created, None).await?;
|
||||||
|
created.replaced = apply_grants(server, account_id, None, Some(&created)).await?;
|
||||||
|
lock::set(data, &created, Some(&created)).await?;
|
||||||
|
invalidate(server, account_id, None, Some(&created)).await?;
|
||||||
|
end_sessions(server, account_id).await;
|
||||||
|
|
||||||
|
let mut out = Map::with_capacity(1);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::Id),
|
||||||
|
Value::Element(AccountLockValue::Id(Id::from(account_id))),
|
||||||
|
);
|
||||||
|
response.created.insert(client_id, Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
let account_id = id.document_id();
|
||||||
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
|
response.not_updated.append(id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(current) = lock::get(data, account_id).await? else {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if reason_of(arguments.reason.as_deref()).is_none() {
|
||||||
|
response.not_updated.append(id, reason_required());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut updated = current.clone();
|
||||||
|
let mut invalid = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
match (&key, value) {
|
||||||
|
(Key::Property(P::Delegates), value) => {
|
||||||
|
match parse_delegates(server, access_token, account_id, value.into_owned()).await {
|
||||||
|
Ok(delegates) => updated.delegates = delegates,
|
||||||
|
Err(error) => {
|
||||||
|
invalid = Some(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
||||||
|
Some(r) => updated.reason = r,
|
||||||
|
None => {
|
||||||
|
invalid = Some(reason_required());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(error) = invalid {
|
||||||
|
response.not_updated.append(id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updated.replaced = apply_grants(server, account_id, Some(¤t), Some(&updated)).await?;
|
||||||
|
lock::set(data, &updated, Some(¤t)).await?;
|
||||||
|
invalidate(server, account_id, Some(¤t), Some(&updated)).await?;
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
let account_id = id.document_id();
|
||||||
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
|
response.not_destroyed.append(id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(current) = lock::get(data, account_id).await? else {
|
||||||
|
response.not_destroyed.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if reason_of(arguments.reason.as_deref()).is_none() {
|
||||||
|
response.not_destroyed.append(id, reason_required());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Grants go first: an unlocked account never keeps its delegates
|
||||||
|
apply_grants(server, account_id, Some(¤t), None).await?;
|
||||||
|
lock::remove(data, ¤t).await?;
|
||||||
|
// Delegates lose the account on their next request: their tokens
|
||||||
|
// are rebuilt without it, on every node
|
||||||
|
invalidate(server, account_id, Some(¤t), None).await?;
|
||||||
|
response.destroyed.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -47,10 +47,12 @@ pub async fn collect_access<F: Future>(f: F) -> (F::Output, Vec<u32>) {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notes an account a method call is about to reach (AU-1.6).
|
/// Notes an account a method call is about to reach (AU-1.6): through
|
||||||
|
/// impersonation, or as a locked account's delegate (AL-9).
|
||||||
pub fn note_access(account_id: u32, access_token: &AccessToken) {
|
pub fn note_access(account_id: u32, access_token: &AccessToken) {
|
||||||
if !access_token.is_member_directly(account_id)
|
if access_token.delegation(account_id).is_some()
|
||||||
&& access_token.has_permission(Permission::Impersonate)
|
|| (!access_token.is_member_directly(account_id)
|
||||||
|
&& access_token.has_permission(Permission::Impersonate))
|
||||||
{
|
{
|
||||||
let _ = REACHED.try_with(|reached| {
|
let _ = REACHED.try_with(|reached| {
|
||||||
let mut reached = reached.borrow_mut();
|
let mut reached = reached.borrow_mut();
|
||||||
@@ -99,6 +101,7 @@ fn before_boxed<'a, T: JmapObject>(
|
|||||||
session: &'a HttpSessionData,
|
session: &'a HttpSessionData,
|
||||||
object: &'a str,
|
object: &'a str,
|
||||||
registry: Option<ObjectType>,
|
registry: Option<ObjectType>,
|
||||||
|
reason: Option<String>,
|
||||||
request: &'a SetRequest<'_, T>,
|
request: &'a SetRequest<'_, T>,
|
||||||
) -> std::pin::Pin<Box<dyn Future<Output = trc::Result<Pending>> + Send + 'a>> {
|
) -> std::pin::Pin<Box<dyn Future<Output = trc::Result<Pending>> + Send + 'a>> {
|
||||||
Box::pin(before(
|
Box::pin(before(
|
||||||
@@ -107,6 +110,7 @@ fn before_boxed<'a, T: JmapObject>(
|
|||||||
session,
|
session,
|
||||||
object,
|
object,
|
||||||
registry,
|
registry,
|
||||||
|
reason,
|
||||||
request,
|
request,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -121,6 +125,7 @@ pub async fn recorded<'x, T, F, Fut>(
|
|||||||
session: &HttpSessionData,
|
session: &HttpSessionData,
|
||||||
object: &str,
|
object: &str,
|
||||||
registry: Option<ObjectType>,
|
registry: Option<ObjectType>,
|
||||||
|
reason: Option<String>,
|
||||||
request: SetRequest<'x, T>,
|
request: SetRequest<'x, T>,
|
||||||
method: F,
|
method: F,
|
||||||
) -> trc::Result<SetResponse<T>>
|
) -> trc::Result<SetResponse<T>>
|
||||||
@@ -135,7 +140,8 @@ where
|
|||||||
// Every inner future is boxed where it's made, never held in this
|
// Every inner future is boxed where it's made, never held in this
|
||||||
// frame: a debug build's stack can't take a copy of registry_set's
|
// frame: a debug build's stack can't take a copy of registry_set's
|
||||||
// state on top of the request's own
|
// state on top of the request's own
|
||||||
let pending = before_boxed(server, access_token, session, object, registry, &request).await?;
|
let pending =
|
||||||
|
before_boxed(server, access_token, session, object, registry, reason, &request).await?;
|
||||||
let result = scope::request(method(request)).await;
|
let result = scope::request(method(request)).await;
|
||||||
after(server, pending, &result).await;
|
after(server, pending, &result).await;
|
||||||
result
|
result
|
||||||
@@ -147,6 +153,7 @@ async fn before<T: JmapObject>(
|
|||||||
session: &HttpSessionData,
|
session: &HttpSessionData,
|
||||||
object: &str,
|
object: &str,
|
||||||
registry: Option<ObjectType>,
|
registry: Option<ObjectType>,
|
||||||
|
reason: Option<String>,
|
||||||
request: &SetRequest<'_, T>,
|
request: &SetRequest<'_, T>,
|
||||||
) -> trc::Result<Pending> {
|
) -> trc::Result<Pending> {
|
||||||
let actor = server.audit_actor(access_token).await;
|
let actor = server.audit_actor(access_token).await;
|
||||||
@@ -236,7 +243,7 @@ async fn before<T: JmapObject>(
|
|||||||
target,
|
target,
|
||||||
changes,
|
changes,
|
||||||
details: None,
|
details: None,
|
||||||
reason: None,
|
reason: reason.clone(),
|
||||||
outcome: Outcome::Pending,
|
outcome: Outcome::Pending,
|
||||||
};
|
};
|
||||||
match server.audit_append(&record).await {
|
match server.audit_append(&record).await {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! `crates/features`; this module only speaks JMAP for them.
|
//! `crates/features`; this module only speaks JMAP for them.
|
||||||
|
|
||||||
pub mod access;
|
pub mod access;
|
||||||
|
pub mod account_lock;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod audit_log;
|
pub mod audit_log;
|
||||||
pub mod ai_limits;
|
pub mod ai_limits;
|
||||||
|
|||||||
@@ -1734,6 +1734,11 @@ pub enum Permission {
|
|||||||
SysAuditGet = 662,
|
SysAuditGet = 662,
|
||||||
SysAuditExport = 663,
|
SysAuditExport = 663,
|
||||||
SysAuditSettingsUpdate = 664,
|
SysAuditSettingsUpdate = 664,
|
||||||
|
// inbuxa: account lock with delegation (audit-hold-lock spec, AL-12)
|
||||||
|
SysAccountLockGet = 665,
|
||||||
|
SysAccountLockCreate = 666,
|
||||||
|
SysAccountLockUpdate = 667,
|
||||||
|
SysAccountLockDestroy = 668,
|
||||||
SysAccountGet = 219,
|
SysAccountGet = 219,
|
||||||
SysAccountCreate = 220,
|
SysAccountCreate = 220,
|
||||||
SysAccountUpdate = 221,
|
SysAccountUpdate = 221,
|
||||||
|
|||||||
@@ -7076,6 +7076,10 @@ impl EnumImpl for Permission {
|
|||||||
b"sysAuditGet" => Permission::SysAuditGet,
|
b"sysAuditGet" => Permission::SysAuditGet,
|
||||||
b"sysAuditExport" => Permission::SysAuditExport,
|
b"sysAuditExport" => Permission::SysAuditExport,
|
||||||
b"sysAuditSettingsUpdate" => Permission::SysAuditSettingsUpdate,
|
b"sysAuditSettingsUpdate" => Permission::SysAuditSettingsUpdate,
|
||||||
|
b"sysAccountLockGet" => Permission::SysAccountLockGet,
|
||||||
|
b"sysAccountLockCreate" => Permission::SysAccountLockCreate,
|
||||||
|
b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate,
|
||||||
|
b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy,
|
||||||
b"sysAccountGet" => Permission::SysAccountGet,
|
b"sysAccountGet" => Permission::SysAccountGet,
|
||||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||||
@@ -7757,6 +7761,10 @@ impl EnumImpl for Permission {
|
|||||||
Permission::SysAuditGet => "sysAuditGet",
|
Permission::SysAuditGet => "sysAuditGet",
|
||||||
Permission::SysAuditExport => "sysAuditExport",
|
Permission::SysAuditExport => "sysAuditExport",
|
||||||
Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate",
|
Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate",
|
||||||
|
Permission::SysAccountLockGet => "sysAccountLockGet",
|
||||||
|
Permission::SysAccountLockCreate => "sysAccountLockCreate",
|
||||||
|
Permission::SysAccountLockUpdate => "sysAccountLockUpdate",
|
||||||
|
Permission::SysAccountLockDestroy => "sysAccountLockDestroy",
|
||||||
Permission::SysAccountGet => "sysAccountGet",
|
Permission::SysAccountGet => "sysAccountGet",
|
||||||
Permission::SysAccountCreate => "sysAccountCreate",
|
Permission::SysAccountCreate => "sysAccountCreate",
|
||||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||||
@@ -8431,6 +8439,10 @@ impl EnumImpl for Permission {
|
|||||||
662 => Some(Permission::SysAuditGet),
|
662 => Some(Permission::SysAuditGet),
|
||||||
663 => Some(Permission::SysAuditExport),
|
663 => Some(Permission::SysAuditExport),
|
||||||
664 => Some(Permission::SysAuditSettingsUpdate),
|
664 => Some(Permission::SysAuditSettingsUpdate),
|
||||||
|
665 => Some(Permission::SysAccountLockGet),
|
||||||
|
666 => Some(Permission::SysAccountLockCreate),
|
||||||
|
667 => Some(Permission::SysAccountLockUpdate),
|
||||||
|
668 => Some(Permission::SysAccountLockDestroy),
|
||||||
219 => Some(Permission::SysAccountGet),
|
219 => Some(Permission::SysAccountGet),
|
||||||
220 => Some(Permission::SysAccountCreate),
|
220 => Some(Permission::SysAccountCreate),
|
||||||
221 => Some(Permission::SysAccountUpdate),
|
221 => Some(Permission::SysAccountUpdate),
|
||||||
@@ -8875,7 +8887,7 @@ impl EnumImpl for Permission {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNT: usize = 665;
|
const COUNT: usize = 669;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for Permission {
|
impl serde::Serialize for Permission {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use common::ipc::{
|
use common::ipc::{
|
||||||
@@ -139,6 +141,11 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
|
|||||||
BroadcastEvent::QueueRefresh => {
|
BroadcastEvent::QueueRefresh => {
|
||||||
serialized.push(12u8);
|
serialized.push(12u8);
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-3
|
||||||
|
BroadcastEvent::EndSessions(account_id) => {
|
||||||
|
serialized.push(13u8);
|
||||||
|
let _ = serialized.write_leb128(*account_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serialized
|
serialized
|
||||||
@@ -272,6 +279,11 @@ where
|
|||||||
10 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: true })),
|
10 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: true })),
|
||||||
11 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: false })),
|
11 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: false })),
|
||||||
12 => Ok(Some(BroadcastEvent::QueueRefresh)),
|
12 => Ok(Some(BroadcastEvent::QueueRefresh)),
|
||||||
|
// inbuxa: AL-3
|
||||||
|
13 => {
|
||||||
|
let account_id = self.messages.next_leb128().ok_or(())?;
|
||||||
|
Ok(Some(BroadcastEvent::EndSessions(account_id)))
|
||||||
|
}
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
.send(QueueEvent::Paused(!is_running))
|
.send(QueueEvent::Paused(!is_running))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-3: sessions an account has
|
||||||
|
// open here end too
|
||||||
|
BroadcastEvent::EndSessions(account_id) => {
|
||||||
|
let _ = inner
|
||||||
|
.ipc
|
||||||
|
.push_tx
|
||||||
|
.send(PushEvent::Revoke { account_id })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
BroadcastEvent::QueueRefresh => {
|
BroadcastEvent::QueueRefresh => {
|
||||||
if inner.shared_core.load().network.roles.outbound_mta {
|
if inner.shared_core.load().network.roles.outbound_mta {
|
||||||
let _ = inner
|
let _ = inner
|
||||||
@@ -266,6 +275,9 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
|
|||||||
BroadcastEvent::PushServerUpdate(account_id) => {
|
BroadcastEvent::PushServerUpdate(account_id) => {
|
||||||
trc::Value::Array(vec!["PushServerUpdate".into(), (*account_id).into()])
|
trc::Value::Array(vec!["PushServerUpdate".into(), (*account_id).into()])
|
||||||
}
|
}
|
||||||
|
BroadcastEvent::EndSessions(account_id) => {
|
||||||
|
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
|
||||||
|
}
|
||||||
BroadcastEvent::RegistryChange(change) => match change {
|
BroadcastEvent::RegistryChange(change) => match change {
|
||||||
RegistryChange::Insert(id) => trc::Value::Array(vec![
|
RegistryChange::Insert(id) => trc::Value::Array(vec![
|
||||||
"RegistryInsert".into(),
|
"RegistryInsert".into(),
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ async fn store_maintenance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: AL-7: locks' grants reach folders the server made on
|
||||||
|
// its own (a Sieve fileinto :create)
|
||||||
|
if let Err(err) = email::inbuxa_lock::reconcile_all(server).await {
|
||||||
|
trc::error!(err.details("Failed to re-apply account locks"));
|
||||||
|
}
|
||||||
|
|
||||||
// inbuxa: AU-7: audit records past their retention go; a
|
// inbuxa: AU-7: audit records past their retention go; a
|
||||||
// failure leaves them for the next run
|
// failure leaves them for the next run
|
||||||
if let Err(err) = server.audit_purge().await {
|
if let Err(err) = server.audit_purge().await {
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
0CZ88XU8AvHiGwlrTmlc5Lt-4dgmms3pJphCNzK5FKI
|
SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Account lock with delegation acceptance tests, from
|
||||||
|
//! `inbuxa-drafts/specs/audit-hold-lock.md` (AL-1 to AL-12; tests 10 to 15
|
||||||
|
//! of its list). Each check names the requirement or test number.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
jmap::mail::submission::{
|
||||||
|
MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server,
|
||||||
|
},
|
||||||
|
utils::{
|
||||||
|
account::Account,
|
||||||
|
dns::DnsCache,
|
||||||
|
server::{TestServer, TestServerBuilder},
|
||||||
|
smtp::SmtpConnection,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
enums::MtaProtocol,
|
||||||
|
structs::{
|
||||||
|
Expression, ExpressionMatch, Imap, MtaOutboundStrategy, MtaRoute, MtaRouteRelay,
|
||||||
|
MtaStageAuth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
types::list::List,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const USING: &[&str] = &[
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:ietf:params:jmap:submission",
|
||||||
|
"urn:inbuxa:jmap",
|
||||||
|
];
|
||||||
|
|
||||||
|
const OWNER_SECRET: &str = "owner-secret-4471";
|
||||||
|
const DELEGATE_SECRET: &str = "delegate-secret-9902";
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
async fn call(&self, method: &str, arguments: Value) -> (String, Value) {
|
||||||
|
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
|
||||||
|
let call = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/0")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
||||||
|
(call[0].as_str().unwrap_or_default().to_string(), call[1].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lock_set(&self, arguments: Value) -> Value {
|
||||||
|
let mut arguments = arguments;
|
||||||
|
arguments["accountId"] = self.id_string().into();
|
||||||
|
let (name, response) = self.call("inbuxa:AccountLock/set", arguments).await;
|
||||||
|
assert_eq!(name, "inbuxa:AccountLock/set", "{response}");
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session_status(&self) -> u16 {
|
||||||
|
self.http_get_raw(&format!("{}/jmap/session", self.base_url()), None)
|
||||||
|
.await
|
||||||
|
.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(from: &str, subject: &str) -> String {
|
||||||
|
format!("From: {from}\r\nTo: [email protected]\r\nSubject: {subject}\r\n\r\nHello.\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test(test: &mut TestServer) {
|
||||||
|
println!("Running account lock tests...");
|
||||||
|
let admin = test.account("[email protected]");
|
||||||
|
let owner = admin
|
||||||
|
.create_user_account("[email protected]", OWNER_SECRET, "Owner", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let delegate = admin
|
||||||
|
.create_user_account("[email protected]", DELEGATE_SECRET, "Delegate", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let owner_id = owner.id_string().to_string();
|
||||||
|
|
||||||
|
// Mail leaving the server goes to the mock
|
||||||
|
let (mut smtp_rx, _smtp_settings) = spawn_mock_smtp_server();
|
||||||
|
test.server.ipv4_add(
|
||||||
|
"localhost",
|
||||||
|
vec!["127.0.0.1".parse().unwrap()],
|
||||||
|
Instant::now() + Duration::from_secs(60),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The owner set a vacation reply before leaving
|
||||||
|
owner
|
||||||
|
.jmap_client()
|
||||||
|
.await
|
||||||
|
.vacation_response_enable("Away", "I'm away.".into(), None::<String>)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Control: before the lock, the vacation reply goes out, so its absence
|
||||||
|
// later means the lock stopped it
|
||||||
|
let mut lmtp = SmtpConnection::connect().await;
|
||||||
|
lmtp.ingest(
|
||||||
|
"[email protected]",
|
||||||
|
&["[email protected]"],
|
||||||
|
&message("[email protected]", "Before the lock"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_message_delivery(
|
||||||
|
&mut smtp_rx,
|
||||||
|
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Away"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let right_password = owner.session_status().await;
|
||||||
|
assert_eq!(right_password, 200, "the owner can sign in before the lock");
|
||||||
|
let wrong = Account::new("[email protected]", "wrong-password", &[], "", owner.id());
|
||||||
|
let wrong_password = wrong.session_status().await;
|
||||||
|
|
||||||
|
// AU-12: no lock without a reason
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"create": {"l": {"accountId": owner_id,
|
||||||
|
"delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["l"]["type"], "invalidProperties",
|
||||||
|
"AU-12: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// AL-12: nobody locks themselves
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"create": {"l": {"accountId": admin.id_string(), "reason": "test"}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["notCreated"]["l"]["type"], "forbidden", "AL-12: {response}");
|
||||||
|
|
||||||
|
// AL-8: send-as needs more than read
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"create": {"l": {"accountId": owner_id, "reason": "Left",
|
||||||
|
"delegates": [{"accountId": delegate.id_string(), "access": "read", "sendAs": true}]}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["l"]["type"], "invalidProperties",
|
||||||
|
"AL-8: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lock, with a read-only delegate (AL-1)
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"create": {"l": {"accountId": owner_id,
|
||||||
|
"reason": "Left the company; mail to be reviewed",
|
||||||
|
"delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["created"]["l"]["id"], owner_id.as_str(), "AL-1: {response}");
|
||||||
|
|
||||||
|
// AL-2: the right password fails as a wrong one does
|
||||||
|
let right_password = owner.session_status().await;
|
||||||
|
assert_ne!(right_password, 200, "AL-2: a locked account signed in");
|
||||||
|
assert_eq!(
|
||||||
|
right_password, wrong_password,
|
||||||
|
"AL-2: the right password is told apart from a wrong one"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 11, AL-4: mail keeps arriving, and nothing is sent: no vacation
|
||||||
|
lmtp.ingest(
|
||||||
|
"[email protected]",
|
||||||
|
&["[email protected]"],
|
||||||
|
&message("[email protected]", "Quarterly report"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
expect_nothing(&mut smtp_rx).await;
|
||||||
|
|
||||||
|
// AL-7: the delegate sees the account, read-only, marked as delegated
|
||||||
|
let session = delegate.jmap_session_object().await.0;
|
||||||
|
let entry = &session["accounts"][owner_id.as_str()];
|
||||||
|
assert_eq!(entry["isPersonal"], false, "AL-7: {session}");
|
||||||
|
assert_eq!(entry["isReadOnly"], true, "AL-6: {entry}");
|
||||||
|
let delegation = &entry["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
|
||||||
|
assert_eq!(delegation["locked"], true, "AL-7: {entry}");
|
||||||
|
assert_eq!(delegation["access"], "read", "AL-7");
|
||||||
|
assert_eq!(delegation["sendAs"], false, "AL-7");
|
||||||
|
|
||||||
|
// The delegate reads the mail that arrived
|
||||||
|
let (_, found) = delegate
|
||||||
|
.call(
|
||||||
|
"Email/query",
|
||||||
|
json!({"accountId": owner_id, "filter": {"text": "Quarterly"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let email_id = found["ids"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("AL-4: the mail didn't arrive: {found}"))
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Test 12, AL-6: read means nothing changes, not even $seen
|
||||||
|
let (_, response) = delegate
|
||||||
|
.call(
|
||||||
|
"Email/set",
|
||||||
|
json!({"accountId": owner_id, "update": {email_id.as_str(): {"keywords/$seen": true}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response["notUpdated"][email_id.as_str()].is_object(),
|
||||||
|
"test 12: a read delegate changed a keyword: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// AU-12: changing delegates needs a reason
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"update": {owner_id.as_str(): {"delegates": [
|
||||||
|
{"accountId": delegate.id_string(), "access": "organize"}]}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][owner_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"AU-12: {response}"
|
||||||
|
);
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"reason": "Manager files the mail",
|
||||||
|
"update": {owner_id.as_str(): {"delegates": [
|
||||||
|
{"accountId": delegate.id_string(), "access": "organize"}]}}}))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response["updated"].get(owner_id.as_str()).is_some(),
|
||||||
|
"AL-5: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 12, AL-6, AL-7: organize makes folders it can see, moves mail,
|
||||||
|
// never deletes it
|
||||||
|
let (_, mailboxes) = delegate
|
||||||
|
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
|
||||||
|
.await;
|
||||||
|
let inbox = mailboxes["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m["role"] == "inbox")
|
||||||
|
.unwrap_or_else(|| panic!("AL-7: no inbox seen: {mailboxes}"))["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let (_, created) = delegate
|
||||||
|
.call(
|
||||||
|
"Mailbox/set",
|
||||||
|
json!({"accountId": owner_id, "create": {"f": {"name": "Reviewed", "parentId": inbox}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let folder = created["created"]["f"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("AL-6: organize couldn't make a folder: {created}"))
|
||||||
|
.to_string();
|
||||||
|
let (_, mailboxes) = delegate
|
||||||
|
.call("Mailbox/get", json!({"accountId": owner_id, "ids": [folder]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
mailboxes["list"].as_array().map(Vec::len),
|
||||||
|
Some(1),
|
||||||
|
"AL-7: the delegate can't see the folder it made: {mailboxes}"
|
||||||
|
);
|
||||||
|
let (_, moved) = delegate
|
||||||
|
.call(
|
||||||
|
"Email/set",
|
||||||
|
json!({"accountId": owner_id, "update": {email_id.as_str(): {
|
||||||
|
"mailboxIds": {folder.as_str(): true}}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
moved["updated"].get(email_id.as_str()).is_some(),
|
||||||
|
"test 12: organize couldn't move mail: {moved}"
|
||||||
|
);
|
||||||
|
let (_, destroyed) = delegate
|
||||||
|
.call(
|
||||||
|
"Email/set",
|
||||||
|
json!({"accountId": owner_id, "destroy": [email_id]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
destroyed["notDestroyed"][email_id.as_str()]["type"], "forbidden",
|
||||||
|
"test 12: organize deleted mail: {destroyed}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// AL-8: no sending without send-as
|
||||||
|
let (name, _) = delegate
|
||||||
|
.call("Identity/get", json!({"accountId": owner_id, "ids": null}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(name, "error", "AL-8: identities of a locked account without send-as");
|
||||||
|
|
||||||
|
// Test 11, AL-4: a Sieve reject is kept instead, and nobody is answered
|
||||||
|
admin
|
||||||
|
.jmap_client()
|
||||||
|
.await
|
||||||
|
.set_default_account_id(owner_id.clone())
|
||||||
|
.sieve_script_create(
|
||||||
|
"rejector",
|
||||||
|
"require \"reject\";\r\nreject \"Not here.\";\r\n",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
lmtp.ingest(
|
||||||
|
"[email protected]",
|
||||||
|
&["[email protected]"],
|
||||||
|
&message("[email protected]", "Invoice 77"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
expect_nothing(&mut smtp_rx).await;
|
||||||
|
let (_, kept) = delegate
|
||||||
|
.call(
|
||||||
|
"Email/query",
|
||||||
|
json!({"accountId": owner_id, "filter": {"text": "Invoice"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
kept["ids"].as_array().map(Vec::len),
|
||||||
|
Some(1),
|
||||||
|
"AL-4: the rejected message wasn't kept: {kept}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// AL-10: unlocking needs a reason, then restores everything
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"destroy": [owner_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notDestroyed"][owner_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"AU-12: {response}"
|
||||||
|
);
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"reason": "Review done", "destroy": [owner_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["destroyed"][0], owner_id.as_str(), "AL-10: {response}");
|
||||||
|
assert_eq!(owner.session_status().await, 200, "AL-10: the owner can sign in");
|
||||||
|
let session = delegate.jmap_session_object().await.0;
|
||||||
|
assert!(
|
||||||
|
session["accounts"].get(owner_id.as_str()).is_none(),
|
||||||
|
"test 15: the delegate kept the account: {session}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// AL-9, AU-12: every step is recorded, with its reason
|
||||||
|
let (_, query) = admin
|
||||||
|
.call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:AccountLock"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, records) = admin
|
||||||
|
.call(
|
||||||
|
"inbuxa:AuditEvent/get",
|
||||||
|
json!({"accountId": admin.id_string(), "ids": query["ids"]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let reasons = records["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r["outcome"]["status"] == "success")
|
||||||
|
.filter_map(|r| r["reason"].as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for reason in [
|
||||||
|
"Left the company; mail to be reviewed",
|
||||||
|
"Manager files the mail",
|
||||||
|
"Review done",
|
||||||
|
] {
|
||||||
|
assert!(reasons.contains(&reason), "AU-12: {reason} missing from {reasons:?}");
|
||||||
|
}
|
||||||
|
let (_, query) = admin
|
||||||
|
.call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"accountId": admin.id_string(),
|
||||||
|
"filter": {"actorId": delegate.id_string(), "accountId": owner_id}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
||||||
|
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
pub async fn account_lock_tests() {
|
||||||
|
let mut test = TestServerBuilder::new("account_lock_tests")
|
||||||
|
.await
|
||||||
|
.with_default_listeners()
|
||||||
|
.await
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
let admin = test.create_admin_account("[email protected]").await;
|
||||||
|
admin
|
||||||
|
.registry_create_object(Imap {
|
||||||
|
allow_plain_text_auth: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_create_object(MtaStageAuth {
|
||||||
|
require: Expression {
|
||||||
|
else_: "false".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_create_object(MtaOutboundStrategy {
|
||||||
|
route: Expression {
|
||||||
|
match_: List::from_iter([
|
||||||
|
ExpressionMatch {
|
||||||
|
if_: "rcpt_domain == 'example.com'".into(),
|
||||||
|
then: "'local'".into(),
|
||||||
|
},
|
||||||
|
ExpressionMatch {
|
||||||
|
if_: "rcpt_domain == 'remote.org'".into(),
|
||||||
|
then: "'mock-smtp'".into(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
else_: "'mx'".to_string(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
|
||||||
|
address: "127.0.0.1".into(),
|
||||||
|
port: 9999,
|
||||||
|
allow_invalid_certs: true,
|
||||||
|
implicit_tls: false,
|
||||||
|
name: "mock-smtp".into(),
|
||||||
|
protocol: MtaProtocol::Smtp,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
admin.reload_settings().await;
|
||||||
|
test.insert_account(admin);
|
||||||
|
self::test(&mut test).await;
|
||||||
|
if test.is_reset() {
|
||||||
|
test.temp_dir.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod authentication;
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod ai_calibration;
|
pub mod ai_calibration;
|
||||||
pub mod ai_explain;
|
pub mod ai_explain;
|
||||||
|
pub mod account_lock; // inbuxa: account lock with delegation
|
||||||
pub mod audit; // inbuxa: the audit log
|
pub mod audit; // inbuxa: the audit log
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||||
|
|||||||
Reference in New Issue
Block a user