diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 2a19a79..5eac3aa 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -35,6 +35,7 @@ const KIND_ACCOUNT_ACCESS: u8 = 0; const KIND_BLOB_ACCESS: u8 = 1; const KIND_SIGN_IN: u8 = 2; 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 /// `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. pub async fn audit_purge(&self) -> trc::Result { let settings = log::settings(self.store()).await?; diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index f6fcefe..408746c 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -43,6 +43,27 @@ impl Server { revision: u64, revision_account: u64, ) -> trc::Result { + // 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 { Account::User(account) => { let tenant_id = account.member_tenant_id.map(|t| t.id() as u32); @@ -202,6 +223,8 @@ impl Server { .upload_max_concurrent .map(ConcurrencyLimiter::new), obj_size: 0, + locked, + delegations: delegations.clone(), revision, revision_account, credential_version, @@ -211,7 +234,15 @@ impl Server { access_to: access_to.into_boxed_slice(), scopes: [] .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::>(), } .update_size()) @@ -245,6 +276,8 @@ impl Server { .upload_max_concurrent .map(ConcurrencyLimiter::new), obj_size: 0, + locked, + delegations: delegations.clone(), revision, revision_account, credential_version: 0, @@ -591,6 +624,8 @@ impl AccessToken { revision: old_inner.revision, credential_version: old_inner.credential_version, obj_size: old_inner.obj_size, + locked: old_inner.locked, + delegations: old_inner.delegations.clone(), }; 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 { + 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). pub fn origin(&self) -> Option<&inbuxa_features::audit::Via> { self.origin.as_deref() @@ -828,6 +887,8 @@ impl AccessToken { revision_account: Default::default(), credential_version: Default::default(), obj_size: Default::default(), + locked: false, + delegations: Default::default(), }), } } @@ -838,6 +899,11 @@ impl AccessToken { } 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 /// roles, its own settings and its tenant, before a credential narrows it pub fn account_has_permission(&self, permission: Permission) -> bool { @@ -881,6 +947,8 @@ impl AccessTokenInner { revision_account: Default::default(), credential_version: Default::default(), obj_size: Default::default(), + locked: false, + delegations: Default::default(), } } diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 045a2ec..bf82ee4 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -44,6 +44,19 @@ impl Server { pub async fn authenticate(&self, req: &AuthRequest) -> trc::Result { match Box::pin(self.route_auth_request(req)) .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)) { Ok(token) => { diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 3bd2710..87cf335 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -150,6 +150,21 @@ pub struct AccessTokenInner { pub(crate) revision: u64, pub(crate) credential_version: 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, } #[derive(Debug, Default, Hash, Clone)] diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 433ff0a..1f09e87 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -275,6 +275,15 @@ impl Default for DefaultPermissions { default.superuser.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 => { let name = permission.as_str(); if name.starts_with("jmap") diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index 75bd155..c79f935 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -86,6 +86,8 @@ pub enum BroadcastEvent { CacheInvalidateNegative, MtaQueueStatus { is_running: bool }, QueueRefresh, + // inbuxa: AL-3: end an account's open sessions on every node + EndSessions(u32), } #[derive(Debug, Clone, Copy)] diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 0043d4b..62b1f6c 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -36,11 +36,23 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAuditGet, Permission::SysAuditExport, Permission::SysAuditSettingsUpdate, + Permission::SysAccountLockGet, + Permission::SysAccountLockCreate, + Permission::SysAccountLockUpdate, + Permission::SysAccountLockDestroy, ]; /// Granted to the default tenant administrator roles: reading and exporting -/// the tenant's audit log (AU-9). -const TENANT_GRANTS: &[Permission] = &[Permission::SysAuditGet, Permission::SysAuditExport]; +/// the tenant's audit log (AU-9), and locking and delegating its accounts +/// (AL-12). +const TENANT_GRANTS: &[Permission] = &[ + Permission::SysAuditGet, + Permission::SysAuditExport, + Permission::SysAccountLockGet, + Permission::SysAccountLockCreate, + Permission::SysAccountLockUpdate, + Permission::SysAccountLockDestroy, +]; #[derive(Clone, Copy, PartialEq, Eq)] enum Audience { diff --git a/crates/dav/src/file/mkcol.rs b/crates/dav/src/file/mkcol.rs index e9d9249..f0bf2e5 100644 --- a/crates/dav/src/file/mkcol.rs +++ b/crates/dav/src/file/mkcol.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::proppatch::FilePropPatchRequestHandler; @@ -131,6 +133,14 @@ impl FileMkColRequestHandler for Server { let etag = batch.etag(); 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 { Ok(HttpResponse::new(StatusCode::CREATED) .with_xml_body( diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs index 85fffe9..121abf2 100644 --- a/crates/dav/src/file/update.rs +++ b/crates/dav/src/file/update.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -299,6 +301,14 @@ impl FileUpdateRequestHandler for Server { let etag = batch.etag(); 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)) } } diff --git a/crates/email/src/inbuxa_lock.rs b/crates/email/src/inbuxa_lock.rs new file mode 100644 index 0000000..22425fc --- /dev/null +++ b/crates/email/src/inbuxa_lock.rs @@ -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> { + 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::>(ValueKey::archive( + account_id, + Collection::Mailbox, + mailbox.document_id, + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let current = archive + .into_deserialized::() + .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(()) +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index ab6cc62..fe3c046 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -14,6 +14,7 @@ pub mod cache; pub mod identity; +pub mod inbuxa_lock; // inbuxa: account lock grants pub mod mailbox; pub mod message; pub mod push; diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index ef13c36..1a7c2c7 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -287,6 +287,18 @@ impl SieveScriptIngest for Server { do_discard = true; 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, .. } => { reject_reason = reason.into(); do_discard = true; @@ -388,6 +400,17 @@ impl SieveScriptIngest for Server { } 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 { recipient, message_id, diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 2d3ac72..617ec59 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -21,6 +21,7 @@ pub mod ai; pub mod audit; pub mod branding; +pub mod lock; pub mod masked_email; pub mod security; pub mod tenancy; diff --git a/crates/features/src/lock/mod.rs b/crates/features/src/lock/mod.rs new file mode 100644 index 0000000..b19e4ff --- /dev/null +++ b/crates/features/src/lock/mod.rs @@ -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 { + 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 { + 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, +} + +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, + #[serde(default)] + pub delegates: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replaced: Vec, +} + +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)> { + 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, +) -> Option> { + 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, account_id: u32, grants: Bitmap| { + 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::>(); + v.sort(); + v + }; + (sorted(&acls) != sorted(current)).then_some(acls) +} + +struct Json(T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize account lock") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + 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 { + 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> { + 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> { + Ok(data + .get_value::>(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> { + 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::::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> { + 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, replaced: Vec) -> 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 = 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::(&json).unwrap(), lock); + assert!(json.contains("\"access\":\"full\"")); + } +} diff --git a/crates/groupware/src/inbuxa_lock.rs b/crates/groupware/src/inbuxa_lock.rs new file mode 100644 index 0000000..e699bfb --- /dev/null +++ b/crates/groupware/src/inbuxa_lock.rs @@ -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, + 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::>(ValueKey::archive( + account_id, + collection, + resource.document_id, + )) + .await + .caused_by(trc::location!())? + else { + continue; + }; + match collection { + Collection::Calendar => { + let current = archive + .to_unarchived::() + .caused_by(trc::location!())?; + let mut changed = current + .deserialize::() + .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::() + .caused_by(trc::location!())?; + let mut changed = current + .deserialize::() + .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::() + .caused_by(trc::location!())?; + let mut changed = current + .deserialize::() + .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::>(); + let mut b = b.iter().map(key).collect::>(); + 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::>(); + 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 +} diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs index 18554ef..e9c91af 100644 --- a/crates/groupware/src/lib.rs +++ b/crates/groupware/src/lib.rs @@ -23,6 +23,7 @@ pub mod calendar; pub mod contact; pub mod file; pub mod inbuxa; // inbuxa: undelete notes +pub mod inbuxa_lock; // inbuxa: account lock grants pub mod scheduling; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index 08e9038..a191f5d 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -239,10 +239,20 @@ impl TokenHandler for Server { .validate_access_token(GrantType::RefreshToken.into(), refresh_token) .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 .issue_token( token_info.account_id, - "", + // inbuxa: AU-5: the client travels in the refresh token + token_info.claims.as_deref().unwrap_or_default(), issuer, None, None, @@ -342,7 +352,8 @@ impl TokenHandler for Server { account_id, account_name, 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(), ) .await? diff --git a/crates/imap/src/core/mod.rs b/crates/imap/src/core/mod.rs index 174bb45..7c41c02 100644 --- a/crates/imap/src/core/mod.rs +++ b/crates/imap/src/core/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use ahash::AHashMap; @@ -198,6 +200,13 @@ impl SessionData { .access_token(self.account_id) .await .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) }) .caused_by(trc::location!()) diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 08c5275..8858168 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -141,6 +143,14 @@ impl SessionData { .await .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!( Imap(trc::ImapEvent::CreateMailbox), SpanId = self.session_id, diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs index b497fb4..918f177 100644 --- a/crates/imap/src/op/expunge.rs +++ b/crates/imap/src/op/expunge.rs @@ -45,14 +45,22 @@ impl Session { let (data, mailbox) = self.state.select_data(); // Validate ACL - if !data - .check_mailbox_acl( - mailbox.id.account_id, - mailbox.id.mailbox_id, - Acl::RemoveItems, - ) + // inbuxa: AL-6: a delegate below full may move mail, never delete it + let may_destroy = data + .refresh_access_token() .await .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 .into_err() @@ -143,6 +151,16 @@ impl SessionData { ) -> trc::Result> { // Obtain message ids 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( self.server .get_cached_messages(account_id) diff --git a/crates/jmap-proto/src/object/inbuxa_account_lock.rs b/crates/jmap-proto/src/object/inbuxa_account_lock.rs new file mode 100644 index 0000000..6634263 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_account_lock.rs @@ -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 { + // 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 { + 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 { + AccountLockProperty::parse(s).ok_or(()) + } +} + +impl Element for AccountLockValue { + type Property = AccountLockProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + 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, +} + +impl<'de> DeserializeArguments<'de> for AccountLockSetArguments { + fn deserialize_argument(&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::()?; + } + 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 for AccountLockValue { + fn from(id: Id) -> Self { + AccountLockValue::Id(id) + } +} + +impl JmapObjectId for AccountLockValue { + fn as_id(&self) -> Option { + match self { + AccountLockValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + 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 { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index a7e26bc..e838bb8 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -21,6 +21,7 @@ pub mod contact; pub mod email; pub mod email_submission; 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_audit; // inbuxa: the audit log pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 736c8fa..5d78608 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -67,6 +67,9 @@ impl Response<'_> { GetResponseMethod::AuditSettings(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::AccountLock(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::ProtocolPolicy(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 6026692..633231e 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -48,6 +48,7 @@ impl Response<'_> { GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, GetRequestMethod::AuditEvent(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::TenantProtocolPolicy(request) => { request.resolve_references(self)? @@ -107,6 +108,9 @@ impl Response<'_> { SetRequestMethod::AuditVerification(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::AccountLock(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::ProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 7fb8787..66909b7 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -133,9 +133,31 @@ pub enum Capabilities { FileNode(FileNodeCapabilities), WebPush(WebPushCapabilities), Inbuxa(InbuxaAccountCapabilities), + // inbuxa: AL-7 + InbuxaDelegated(InbuxaDelegatedCapabilities), 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, +} + /// inbuxa: `urn:inbuxa:jmap` on the signed-in principal's own account. #[derive(Debug, Clone, serde::Serialize)] pub struct InbuxaAccountCapabilities { diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index f47c1bf..b404836 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -56,6 +56,8 @@ pub enum MethodObject { AuditSettings, AuditExport, AuditVerification, + // inbuxa: account lock with delegation + AccountLock, ProtocolPolicy, TenantProtocolPolicy, } @@ -88,7 +90,8 @@ impl MethodObject { MethodObject::AuditEvent | MethodObject::AuditSettings | MethodObject::AuditExport - | MethodObject::AuditVerification => Capability::Inbuxa, + | MethodObject::AuditVerification + | MethodObject::AccountLock => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -274,6 +277,8 @@ impl MethodName { (MethodFunction::Get, MethodObject::AuditSettings) => "inbuxa:AuditSettings/get", (MethodFunction::Set, MethodObject::AuditSettings) => "inbuxa:AuditSettings/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) => { "inbuxa:AuditVerification/set" } @@ -416,6 +421,8 @@ impl MethodName { "inbuxa:AuditSettings/get" => (MethodObject::AuditSettings, MethodFunction::Get), "inbuxa:AuditSettings/set" => (MethodObject::AuditSettings, 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:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), @@ -479,6 +486,7 @@ impl Display for MethodObject { MethodObject::AuditSettings => "inbuxa:AuditSettings", MethodObject::AuditExport => "inbuxa:AuditExport", MethodObject::AuditVerification => "inbuxa:AuditVerification", + MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::Registry(obj) => { diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 6325cbf..47e3ee2 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -118,6 +118,7 @@ pub enum GetRequestMethod { AiLimits(Box>), AuditEvent(Box>), AuditSettings(Box>), + AccountLock(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, @@ -149,6 +150,7 @@ pub enum SetRequestMethod<'x> { AuditSettings(Box>), AuditExport(Box>), AuditVerification(Box>), + AccountLock(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index b7e06fe..a909ddf 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -551,6 +551,21 @@ impl<'de> Visitor<'de> for CallVisitor { 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 (MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 014979c..1fe6925 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -105,6 +105,7 @@ pub enum GetResponseMethod { AiLimits(GetResponse), AuditEvent(GetResponse), AuditSettings(GetResponse), + AccountLock(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( GetResponse, @@ -136,6 +137,7 @@ pub enum SetResponseMethod { AuditSettings(Box>), AuditExport(Box>), AuditVerification(Box>), + AccountLock(Box>), Explanation(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -750,3 +752,16 @@ impl<'x> From> for R ResponseMethod::Set(SetResponseMethod::AuditVerification(Box::new(value))) } } + +// inbuxa: account lock with delegation +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::AccountLock(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value))) + } +} diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 93b63d0..eb26e5e 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -21,6 +21,8 @@ use types::{collection::Collection, id::Id}; pub trait JmapAuthorization { 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( &self, request: &RequestMethod, @@ -31,6 +33,17 @@ pub trait JmapAuthorization { } 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> { if self.is_member(account_id.document_id()) { Ok(self) @@ -81,6 +94,8 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => { Permission::SysAuditGet } + // inbuxa: account lock (AL-12) + GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -199,6 +214,14 @@ impl JmapAuthorization for AccessToken { 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( s, self, @@ -345,6 +368,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::AuditSettings | MethodObject::AuditExport | MethodObject::AuditVerification + | MethodObject::AccountLock | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 4915b2c..e2803f4 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -143,15 +143,27 @@ impl RequestHandler for Server { | RequestMethod::Changes(_) | RequestMethod::QueryChanges(_) ); - if matches!( + let is_write = matches!( call.method, RequestMethod::Set(_) | RequestMethod::Copy(_) | RequestMethod::ImportEmail(_) | RequestMethod::UploadBlob(_) - ) { + ); + if is_write { 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 { RequestMethod::Changes(changes) => match &changes.since_state { jmap_proto::types::state::State::Exact(change_id) => { @@ -189,7 +201,28 @@ impl RequestHandler for Server { }; let (result, reached) = result; 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 { @@ -237,6 +270,9 @@ impl RequestHandler for Server { SetResponseMethod::AuditVerification(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::AccountLock(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::Explanation(set_response) => { set_response.update_created_ids(&mut response); } @@ -354,13 +390,15 @@ impl RequestHandler for Server { } GetRequestMethod::Identity(mut req) => { 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() } GetRequestMethod::EmailSubmission(mut req) => { 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() } @@ -401,6 +439,13 @@ impl RequestHandler for Server { .await? .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) GetRequestMethod::AuditEvent(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -526,7 +571,8 @@ impl RequestHandler for Server { } QueryRequestMethod::EmailSubmission(mut req) => { 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() } @@ -631,7 +677,8 @@ impl RequestHandler for Server { } SetRequestMethod::EmailSubmission(mut req) => { 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) .await? @@ -665,6 +712,7 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), None, + None, *req, |req| Box::pin(crate::inbuxa::fastmail::set(self, access_token, req)), ) @@ -681,6 +729,7 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), None, + None, *req, |req| Box::pin(crate::inbuxa::deleted_account::set(self, access_token, req)), ) @@ -697,6 +746,7 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), None, + None, *req, |req| Box::pin(crate::inbuxa::ai_limits::set(self, access_token, req)), ) @@ -712,12 +762,41 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), None, + None, *req, |req| Box::pin(crate::inbuxa::audit_log::settings_set(self, access_token, req)), ) .await? .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) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::audit_log::export_set(self, access_token, session, *req) @@ -747,6 +826,7 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), None, + None, *req, |req| Box::pin(crate::inbuxa::protocol_policy::set(self, access_token, req)), ) @@ -763,6 +843,7 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), None, + None, *req, |req| Box::pin(crate::inbuxa::tenant_protocol_policy::set(self, access_token, req)), ) @@ -840,6 +921,7 @@ impl RequestHandler for Server { session, &method_name.obj.to_string(), Some(object_type), + None, *req, |req| Box::pin(self.registry_set(object_type, req, access_token, session)), ) diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 4dd09b9..f74feb3 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -8,7 +8,7 @@ use common::{Server, auth::AccessToken}; 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 std::future::Future; @@ -116,11 +116,16 @@ impl SessionHandler for Server { 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 mut account = Account { name: account.name().to_string(), 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()), }; for capability in access_token.account_capabilities() { @@ -132,6 +137,22 @@ impl SessionHandler for Server { .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); } diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 5b3389e..537cd0c 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -423,6 +423,7 @@ impl IntermediateChangesResponse { | MethodObject::AuditSettings | MethodObject::AuditExport | MethodObject::AuditVerification + | MethodObject::AccountLock | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index 7120b70..b18182e 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -1141,7 +1143,20 @@ impl EmailSet for Server { } // 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 can_destroy_message_ids = if access_token.is_shared(account_id) { cache.shared_messages(access_token, Acl::RemoveItems).into() diff --git a/crates/jmap/src/inbuxa/account_lock.rs b/crates/jmap/src/inbuxa/account_lock.rs new file mode 100644 index 0000000..4f5479c --- /dev/null +++ b/crates/jmap/src/inbuxa/account_lock.rs @@ -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

> { + 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, SetError

> { + 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 = 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, +) -> trc::Result> { + 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 { + reason + .map(str::trim) + .filter(|r| !r.is_empty()) + .map(|r| r.chars().take(500).collect()) +} + +fn reason_required() -> SetError

{ + 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> { + 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) +} diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index de0be04..2fc61fa 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -47,10 +47,12 @@ pub async fn collect_access(f: F) -> (F::Output, Vec) { .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) { - if !access_token.is_member_directly(account_id) - && access_token.has_permission(Permission::Impersonate) + if access_token.delegation(account_id).is_some() + || (!access_token.is_member_directly(account_id) + && access_token.has_permission(Permission::Impersonate)) { let _ = REACHED.try_with(|reached| { let mut reached = reached.borrow_mut(); @@ -99,6 +101,7 @@ fn before_boxed<'a, T: JmapObject>( session: &'a HttpSessionData, object: &'a str, registry: Option, + reason: Option, request: &'a SetRequest<'_, T>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(before( @@ -107,6 +110,7 @@ fn before_boxed<'a, T: JmapObject>( session, object, registry, + reason, request, )) } @@ -121,6 +125,7 @@ pub async fn recorded<'x, T, F, Fut>( session: &HttpSessionData, object: &str, registry: Option, + reason: Option, request: SetRequest<'x, T>, method: F, ) -> trc::Result> @@ -135,7 +140,8 @@ where // 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 // 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; after(server, pending, &result).await; result @@ -147,6 +153,7 @@ async fn before( session: &HttpSessionData, object: &str, registry: Option, + reason: Option, request: &SetRequest<'_, T>, ) -> trc::Result { let actor = server.audit_actor(access_token).await; @@ -236,7 +243,7 @@ async fn before( target, changes, details: None, - reason: None, + reason: reason.clone(), outcome: Outcome::Pending, }; match server.audit_append(&record).await { diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 1f62fec..95b9e26 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -8,6 +8,7 @@ //! `crates/features`; this module only speaks JMAP for them. pub mod access; +pub mod account_lock; pub mod audit; pub mod audit_log; pub mod ai_limits; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 2ba8a41..c75ac84 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1734,6 +1734,11 @@ pub enum Permission { SysAuditGet = 662, SysAuditExport = 663, SysAuditSettingsUpdate = 664, + // inbuxa: account lock with delegation (audit-hold-lock spec, AL-12) + SysAccountLockGet = 665, + SysAccountLockCreate = 666, + SysAccountLockUpdate = 667, + SysAccountLockDestroy = 668, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index acb5d87..922135c 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7076,6 +7076,10 @@ impl EnumImpl for Permission { b"sysAuditGet" => Permission::SysAuditGet, b"sysAuditExport" => Permission::SysAuditExport, 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"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7757,6 +7761,10 @@ impl EnumImpl for Permission { Permission::SysAuditGet => "sysAuditGet", Permission::SysAuditExport => "sysAuditExport", Permission::SysAuditSettingsUpdate => "sysAuditSettingsUpdate", + Permission::SysAccountLockGet => "sysAccountLockGet", + Permission::SysAccountLockCreate => "sysAccountLockCreate", + Permission::SysAccountLockUpdate => "sysAccountLockUpdate", + Permission::SysAccountLockDestroy => "sysAccountLockDestroy", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8431,6 +8439,10 @@ impl EnumImpl for Permission { 662 => Some(Permission::SysAuditGet), 663 => Some(Permission::SysAuditExport), 664 => Some(Permission::SysAuditSettingsUpdate), + 665 => Some(Permission::SysAccountLockGet), + 666 => Some(Permission::SysAccountLockCreate), + 667 => Some(Permission::SysAccountLockUpdate), + 668 => Some(Permission::SysAccountLockDestroy), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 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 { diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs index 108b413..0c859f0 100644 --- a/crates/services/src/broadcast/mod.rs +++ b/crates/services/src/broadcast/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::ipc::{ @@ -139,6 +141,11 @@ impl BroadcastBatch> { BroadcastEvent::QueueRefresh => { serialized.push(12u8); } + // inbuxa: AL-3 + BroadcastEvent::EndSessions(account_id) => { + serialized.push(13u8); + let _ = serialized.write_leb128(*account_id); + } } } serialized @@ -272,6 +279,11 @@ where 10 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: true })), 11 => Ok(Some(BroadcastEvent::MtaQueueStatus { is_running: false })), 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(()), } } else { diff --git a/crates/services/src/broadcast/subscriber.rs b/crates/services/src/broadcast/subscriber.rs index ad5bb3e..2ca21f9 100644 --- a/crates/services/src/broadcast/subscriber.rs +++ b/crates/services/src/broadcast/subscriber.rs @@ -180,6 +180,15 @@ pub fn spawn_broadcast_subscriber(inner: Arc, mut shutdown_rx: watch::Rec .send(QueueEvent::Paused(!is_running)) .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 => { if inner.shared_core.load().network.roles.outbound_mta { let _ = inner @@ -266,6 +275,9 @@ fn log_event(event: &BroadcastEvent) -> trc::Value { BroadcastEvent::PushServerUpdate(account_id) => { 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 { RegistryChange::Insert(id) => trc::Value::Array(vec![ "RegistryInsert".into(), diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index 89eb916..9b69c2b 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -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 // failure leaves them for the next run if let Err(err) = server.audit_purge().await { diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index c7264e8..a855484 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 4ee30b7..ffa25a9 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -0CZ88XU8AvHiGwlrTmlc5Lt-4dgmms3pJphCNzK5FKI \ No newline at end of file +SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc \ No newline at end of file diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs new file mode 100644 index 0000000..316dd15 --- /dev/null +++ b/tests/src/system/account_lock.rs @@ -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: owner@example.com\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("admin@example.com"); + let owner = admin + .create_user_account("owner@example.com", OWNER_SECRET, "Owner", &[], vec![]) + .await; + let delegate = admin + .create_user_account("delegate@example.com", 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::) + .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( + "dave@remote.org", + &["owner@example.com"], + &message("dave@remote.org", "Before the lock"), + ) + .await; + assert_message_delivery( + &mut smtp_rx, + MockMessage::new("", [""], "@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("owner@example.com", "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( + "bill@remote.org", + &["owner@example.com"], + &message("bill@remote.org", "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( + "carol@remote.org", + &["owner@example.com"], + &message("carol@remote.org", "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::>(); + 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("admin@example.com").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(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index d1d6ea6..4e27ebd 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -11,6 +11,7 @@ pub mod authentication; pub mod ai; pub mod ai_calibration; pub mod ai_explain; +pub mod account_lock; // inbuxa: account lock with delegation pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once