diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 5eac3aa..488154b 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -14,6 +14,7 @@ use crate::{ auth::{AccessToken, AuthRequest, permissions::DefaultPermissions}, }; use directory::Credentials; +use inbuxa_features::hold::{self, Member}; use inbuxa_features::audit::{ Action, Actor, AuditLog, EntryId, Outcome, Record, Target, Via, diff, log, scope, }; @@ -448,7 +449,16 @@ impl Server { pub async fn audit_purge(&self) -> trc::Result { let settings = log::settings(self.store()).await?; let cutoff = ms().saturating_sub(settings.keep_for_secs.saturating_mul(1000)); - log::purge(self.store(), cutoff, |_| false).await + // LH-6, AU-7: a record about a held account stays while it's held. + // Worked out before the purge, which can't wait on lookups. + let held = self.held_accounts().await?; + log::purge(self.store(), cutoff, |record| { + record + .target + .account_id + .is_some_and(|account_id| held.contains(&account_id)) + }) + .await } } @@ -495,6 +505,20 @@ impl RegistryWriteHook for SystemWrites { change: RegistryChange<'a>, ) -> Pin + Send + 'a>> { Box::pin(async move { + // LH-2: every change to an account, whoever makes it: one that + // leaves a held domain, group or tenant stays held by name + if change.object_type == ObjectType::Account + && let (Some(before), Some(after)) = (change.before, change.after) + && let (Some(before), Some(after)) = ( + Member::of(change.id.document_id(), &before.inner), + Member::of(change.id.document_id(), &after.inner), + ) + && let Err(err) = hold::keep_moved(&self.data, &before, &after).await + { + trc::error!(err + .account_id(after.account) + .details("Failed to keep a moved account under its legal hold")); + } let subsystem = match scope::current() { Some(scope::Scope::Request | scope::Scope::Quiet) => return, Some(scope::Scope::System(subsystem)) => subsystem, diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 1f09e87..6b6400b 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -104,6 +104,16 @@ impl Server { ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled); // inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant permissions.disabled.set(Permission::Impersonate as usize); + // inbuxa: LH-13: only server-level administrators see or place + // holds, and a hold may concern the tenant's own administrator + for permission in [ + Permission::SysLegalHoldGet, + Permission::SysLegalHoldCreate, + Permission::SysLegalHoldUpdate, + Permission::SysLegalHoldExport, + ] { + permissions.disabled.set(permission as usize); + } Ok(()) } @@ -254,6 +264,11 @@ impl Default for DefaultPermissions { default.tenant.push(permission); } Permission::Impersonate + // inbuxa: LH-13: holds are the server administrator's alone + | Permission::SysLegalHoldGet + | Permission::SysLegalHoldCreate + | Permission::SysLegalHoldUpdate + | Permission::SysLegalHoldExport | Permission::UnlimitedRequests | Permission::UnlimitedUploads | Permission::LiveMetrics diff --git a/crates/common/src/hold.rs b/crates/common/src/hold.rs new file mode 100644 index 0000000..197e7ec --- /dev/null +++ b/crates/common/src/hold.rs @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: which legal holds cover an account (audit-hold-lock spec, LH-2, +//! LH-11), for the paths that destroy data. Read from the store every time, +//! not cached: a hold placed on one node must bind every node at once, and +//! there are few holds. + +use crate::Server; +use ahash::AHashMap; +use inbuxa_features::{ + hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until}, + undelete::records, +}; +use inbuxa_features::undelete::data::{self as undelete_data, KeptAccount}; +use registry::{ + pickle::PickledStream, + schema::{ + prelude::{ObjectInner, ObjectType}, + structs::ArchivedItem, + }, +}; +use store::{registry::RegistryQuery, write::now}; +use trc::AddContext; +use types::id::Id; + +/// The grace a released item gets at least (LH-10): a release made in error +/// can be undone by placing a new hold within it. +const RELEASE_GRACE: u64 = 30 * 86_400; + +/// What a settle pass changed. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Settled { + pub frozen: usize, + pub released: usize, + /// Deleted accounts kept by a hold, or let go by a release (LH-8, LH-10). + pub accounts_frozen: usize, + pub accounts_released: usize, +} + +/// What one hold keeps (LH-9). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct HoldSummary { + pub accounts: u64, + pub items: u64, + pub size: u64, +} + +/// A kept account as it was when deleted, for a hold's scope: its record +/// still names its domain, groups and tenant. +pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member { + PickledStream::new(&kept.record) + .and_then(|mut stream| ObjectInner::unpickle(ObjectType::Account, &mut stream)) + .and_then(|inner| Member::of(account_id, &inner)) + .unwrap_or(Member { + account: account_id, + ..Default::default() + }) +} + +impl Server { + /// What decides whether a hold reaches a live account; None if it's gone. + pub async fn member_of(&self, account_id: u32) -> Option { + let account = self.account(account_id).await.ok()?; + let mut domains = account + .addresses + .iter() + .map(|address| address.domain_id) + .collect::>(); + domains.sort_unstable(); + domains.dedup(); + Some(Member { + account: account_id, + domains, + groups: account.id_member_of.iter().copied().collect(), + tenant: account.id_tenant, + }) + } + + /// LH-9, the console's "what's held": per active hold, the accounts it + /// covers now (deleted ones it keeps included), and the archived items + /// it keeps with their size. One pass over accounts and archive. + pub async fn hold_summaries(&self) -> trc::Result> { + let data = self.store(); + let registry = self.registry(); + let holds = hold::active(data).await?; + let mut summaries: AHashMap = + holds.iter().map(|h| (h.id, HoldSummary::default())).collect(); + if holds.is_empty() { + return Ok(summaries); + } + let mut members: AHashMap = AHashMap::new(); + for id in registry + .query::>(RegistryQuery::new(ObjectType::Account)) + .await + .caused_by(trc::location!())? + { + if let Some(member) = self.member_of(id.document_id()).await { + members.insert(id.document_id(), member); + } + } + for (account_id, kept) in undelete_data::kept_accounts(data).await? { + members.insert(account_id, kept_member(account_id, &kept)); + } + for member in members.values() { + for hold in holds.iter().filter(|h| h.scope.covers(member)) { + summaries.entry(hold.id).or_default().accounts += 1; + } + } + for id in records::all(data, registry).await? { + let Some(item) = registry.object::(id).await? else { + continue; + }; + if !is_held_until(item.archived_until().timestamp().max(0) as u64) { + continue; + } + let Some(member) = members.get(&item.account_id().document_id()) else { + continue; + }; + let size = match &item { + ArchivedItem::Email(email) => email.size, + ArchivedItem::FileNode(_) => match undelete_data::extra(data, id).await? { + Some(inbuxa_features::undelete::data::Extra::FileNode { size, .. }) => size as u64, + _ => 0, + }, + _ => 0, + }; + for hold in holds.iter().filter(|h| h.scope.covers(member)) { + let summary = summaries.entry(hold.id).or_default(); + summary.items += 1; + summary.size += size; + } + } + Ok(summaries) + } + + /// The active holds covering `account_id`, through its own name, its + /// addresses' domains, its groups or its tenant. Empty for an account + /// that no longer exists: a deleted one is kept by LH-8's own check. + pub async fn holds_on(&self, account_id: u32) -> trc::Result> { + let Ok(account) = self.account(account_id).await else { + return Ok(Vec::new()); + }; + let mut domains = account + .addresses + .iter() + .map(|address| address.domain_id) + .collect::>(); + domains.sort_unstable(); + domains.dedup(); + let member = Member { + account: account_id, + domains, + groups: account.id_member_of.iter().copied().collect(), + tenant: account.id_tenant, + }; + hold::covering(self.store(), &member).await + } + + /// How `account_id`'s deleted items are kept: its holds' ranges and the + /// undelete period in force now (LH-4, UD-6a). + pub async fn keeping(&self, account_id: u32) -> trc::Result { + let retention = inbuxa_features::undelete::settings::retention(self.registry()) + .await? + .items; + Ok(Keeping::new(retention, &self.holds_on(account_id).await?)) + } + + /// LH-6, LH-10, LH-11: brings the whole archive in line with the active + /// holds. An archived item a hold covers is frozen (no deadline), its + /// old deadline noted; a frozen one no hold covers any more gets that + /// deadline back, or release plus 30 days if later. Run after every + /// change to a hold; it changes nothing twice. + pub async fn settle_archive(&self) -> trc::Result { + let data = self.store(); + let registry = self.registry(); + let any_active = !hold::active(data).await?.is_empty(); + let now = now(); + let mut keeping: AHashMap> = AHashMap::new(); + let mut settled = Settled::default(); + for id in records::all(data, registry).await? { + let Some(item) = registry.object::(id).await? else { + continue; + }; + let account_id = item.account_id().document_id(); + if !keeping.contains_key(&account_id) { + // An account that's gone can't be placed in a domain or + // tenant any more: None, and its items are left as they are + let known = self.account(account_id).await.is_ok(); + let value = if known { Some(self.keeping(account_id).await?) } else { None }; + keeping.insert(account_id, value); + } + let until = item.archived_until().timestamp().max(0) as u64; + let held = is_held_until(until); + let covered = match keeping.get(&account_id).and_then(Option::as_ref) { + Some(keeping) => match &item { + ArchivedItem::Email(email) => { + keeping.covers(Some(email.received_at.timestamp().max(0) as u64)) + } + ArchivedItem::CalendarEvent(event) => keeping + .covers_event(event.start_time.map(|t| t.timestamp().max(0) as u64)), + _ => keeping.covers(None), + }, + // Gone: release only once no hold is active anywhere + None => held && any_active, + }; + if covered && !held { + hold::set_original_deadline(data, id.id(), Some(until)).await?; + records::set_deadline(data, registry, id, &item, HELD_UNTIL).await?; + settled.frozen += 1; + } else if !covered && held { + let original = hold::original_deadline(data, id.id()).await?.unwrap_or(0); + records::set_deadline(data, registry, id, &item, original.max(now + RELEASE_GRACE)) + .await?; + hold::set_original_deadline(data, id.id(), None).await?; + settled.released += 1; + } + } + + // LH-8, LH-10: deleted accounts kept by undelete follow the holds + // too. Their DestroyAccount task defers itself while they're kept. + let retention = inbuxa_features::undelete::settings::retention(registry) + .await? + .accounts; + for (account_id, mut kept) in undelete_data::kept_accounts(data).await? { + let covered = !hold::covering(data, &kept_member(account_id, &kept)).await?.is_empty(); + let held = is_held_until(kept.kept_until); + let until = if covered && !held { + settled.accounts_frozen += 1; + HELD_UNTIL + } else if !covered && held { + settled.accounts_released += 1; + (kept.deleted_at + retention.unwrap_or(0)).max(now + RELEASE_GRACE) + } else { + continue; + }; + kept.kept_until = until; + let mut batch = store::write::BatchBuilder::new(); + undelete_data::set_kept_account(&mut batch, account_id, &kept)?; + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + Ok(settled) + } + + /// LH-8: whether a hold covers a deleted account undelete keeps. + pub async fn is_kept_held(&self, account_id: u32, kept: &KeptAccount) -> trc::Result { + Ok(!hold::covering(self.store(), &kept_member(account_id, kept)) + .await? + .is_empty()) + } + + /// Every account an active hold covers now. Empty, without looking at + /// accounts, when nothing is held. + pub async fn held_accounts(&self) -> trc::Result> { + let mut held = ahash::AHashSet::new(); + if hold::active(self.store()).await?.is_empty() { + return Ok(held); + } + for id in self + .registry() + .query::>(RegistryQuery::new(ObjectType::Account)) + .await + .caused_by(trc::location!())? + { + let account_id = id.document_id(); + if self.is_held(account_id).await? { + held.insert(account_id); + } + } + Ok(held) + } + + /// Whether any active hold covers `account_id` at all. + pub async fn is_held(&self, account_id: u32) -> trc::Result { + Ok(!self.holds_on(account_id).await?.is_empty()) + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 54b0f53..bbf2d71 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -68,6 +68,7 @@ use utils::{ pub mod auth; pub mod cache; pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU) +pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH) pub mod config; pub mod expr; pub mod i18n; diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 62b1f6c..f0220cc 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -29,8 +29,8 @@ use trc::AddContext; use types::id::Id; /// Granted to the default administrator roles: "Explain this" -/// (ai-explain spec, EX-4: superuser by default), and the audit log -/// (audit-hold-lock spec, AU-9). +/// (ai-explain spec, EX-4: superuser by default), the audit log, account +/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13). const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAiExplain, Permission::SysAuditGet, @@ -40,6 +40,10 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAccountLockCreate, Permission::SysAccountLockUpdate, Permission::SysAccountLockDestroy, + Permission::SysLegalHoldGet, + Permission::SysLegalHoldCreate, + Permission::SysLegalHoldUpdate, + Permission::SysLegalHoldExport, ]; /// Granted to the default tenant administrator roles: reading and exporting diff --git a/crates/email/src/mailbox/destroy.rs b/crates/email/src/mailbox/destroy.rs index 91e89ef..1e095e8 100644 --- a/crates/email/src/mailbox/destroy.rs +++ b/crates/email/src/mailbox/destroy.rs @@ -92,10 +92,8 @@ impl MailboxDestroy for Server { let mut deleted_ids = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new(); - // inbuxa: UD-1, UD-6a: the retention in force now - let retention = inbuxa_features::undelete::settings::retention(self.registry()) - .await? - .items; + // inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept + let keeping = self.keeping(account_id).await?; self.archives( account_id, Collection::Email, @@ -125,10 +123,10 @@ impl MailboxDestroy for Server { deleted_ids.insert(message_id); thread_ids.insert(prev_message_data.inner.thread_id.to_native()); // inbuxa: UD-1, UD-4: a deleted message is noted for archiving - if let Some(retention) = retention { + if keeping.keeps_anything() { inbuxa_features::undelete::email::note( &mut batch, - retention, + &keeping, account_id, message_id, prev_message_data.inner.size.to_native() as u64, diff --git a/crates/email/src/message/delete.rs b/crates/email/src/message/delete.rs index cc781f0..256fb37 100644 --- a/crates/email/src/message/delete.rs +++ b/crates/email/src/message/delete.rs @@ -69,10 +69,8 @@ impl EmailDeletion for Server { batch .with_account_id(account_id) .with_collection(Collection::Email); - // inbuxa: UD-1, UD-6a: the retention in force now - let retention = inbuxa_features::undelete::settings::retention(self.registry()) - .await? - .items; + // inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept + let keeping = self.keeping(account_id).await?; self.archives( account_id, Collection::Email, @@ -90,10 +88,10 @@ impl EmailDeletion for Server { } thread_ids.insert(metadata.inner.thread_id.to_native()); // inbuxa: UD-1, UD-4: a deleted message is noted for archiving - if let Some(retention) = retention { + if keeping.keeps_anything() { inbuxa_features::undelete::email::note( batch, - retention, + &keeping, account_id, document_id, metadata.inner.size.to_native() as u64, diff --git a/crates/email/src/sieve/delete.rs b/crates/email/src/sieve/delete.rs index c982ada..9ac94b6 100644 --- a/crates/email/src/sieve/delete.rs +++ b/crates/email/src/sieve/delete.rs @@ -44,12 +44,12 @@ impl SieveScriptDelete for Server { )) .await? { - // inbuxa: UD-1: a deleted script is kept, when archiving is on - if let Some(retention) = - inbuxa_features::undelete::settings::retention(self.registry()) - .await? - .items - { + // inbuxa: UD-1, LH-4: a deleted script is kept, when archiving + // is on or a hold covers the account (whole: scripts have no date) + let keeping = self.keeping(account_id).await?; + let now = store::write::now(); + if let Some(until) = keeping.until(now, keeping.is_held()) { + let retention = until.saturating_sub(now); let script = obj_ .deserialize::() .caused_by(trc::location!())?; diff --git a/crates/features/src/hold/mod.rs b/crates/features/src/hold/mod.rs new file mode 100644 index 0000000..cdee60f --- /dev/null +++ b/crates/features/src/hold/mod.rs @@ -0,0 +1,669 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Legal holds (audit-hold-lock spec, LH-1 to LH-14). +//! +//! A hold names a case and what it covers: accounts, groups, domains, +//! tenants or the whole server, optionally only items dated inside a range. +//! While any active hold covers an item, nothing may destroy it. A hold is +//! never deleted: releasing it keeps it, read-only, for the audit trail. +//! +//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts +//! with `H`, then one byte for the kind: +//! +//! - `h` + hold id (u32): the hold, as JSON. +//! +//! Numbers are big-endian. There are few holds, so they're read whole. + +use registry::schema::{prelude::ObjectInner, structs::Account}; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use trc::AddContext; + +/// The deadline a held archived item carries: the last second of 9999. It +/// never passes, so every expiry check keeps the item without knowing about +/// holds (LH-4, LH-5); releasing a hold gives it a real deadline (LH-10). +pub const HELD_UNTIL: u64 = 253_402_300_799; + +/// Whether an archived item's deadline marks it as held. Anything past the +/// year 9000 counts, so a deadline computed from a hold a moment earlier or +/// later still reads as held. +pub fn is_held_until(until: u64) -> bool { + until >= 221_845_392_000 +} + +/// A day, in seconds: the slack either side of a range for an event's start, +/// whose time zone isn't known here. +const DAY: u64 = 86_400; + +/// How an account's deleted items are kept: its holds' ranges, and the +/// undelete period for whatever no hold covers (LH-3, LH-4). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Keeping { + /// `archiveDeletedItemsFor`, in seconds, if undelete is on. + pub retention: Option, + /// Each active hold's range on this account; `(None, None)` is a whole + /// account. Empty when nothing holds it. + pub ranges: Vec<(Option, Option)>, +} + +impl Keeping { + pub fn new(retention: Option, holds: &[Hold]) -> Keeping { + Keeping { + retention, + ranges: holds.iter().map(|h| (h.from, h.to)).collect(), + } + } + + /// Whether any hold reaches the account at all. + pub fn is_held(&self) -> bool { + !self.ranges.is_empty() + } + + /// Whether deleted items need noting: something may keep them. + pub fn keeps_anything(&self) -> bool { + self.is_held() || self.retention.is_some() + } + + /// Whether a hold covers an item dated `date`. No date means the item is + /// held whole, whatever the range (LH-3). + pub fn covers(&self, date: Option) -> bool { + self.ranges.iter().any(|(from, to)| match date { + None => true, + Some(at) => { + from.is_none_or(|from| at >= from) && to.is_none_or(|to| at <= to) + } + }) + } + + /// Like `covers`, for an event's start: a day of slack either side, since + /// its time zone isn't known here. + pub fn covers_event(&self, start: Option) -> bool { + self.ranges.iter().any(|(from, to)| match start { + None => true, + Some(at) => { + from.is_none_or(|from| at + DAY >= from) + && to.is_none_or(|to| at <= to.saturating_add(DAY)) + } + }) + } + + /// Until when an item deleted at `now` is kept: held, the undelete + /// period, or not at all. + pub fn until(&self, now: u64, held: bool) -> Option { + if held { + Some(HELD_UNTIL) + } else { + self.retention.map(|retention| now + retention) + } + } +} + +const FEATURE: u8 = b'H'; +const KIND_HOLD: u8 = b'h'; +const KIND_ORIGINAL: u8 = b'o'; + +/// How many times creating a hold retries when another node took its id. +const CREATE_ATTEMPTS: usize = 5; + +/// What a hold covers (LH-1, LH-2). Domains and tenants are resolved live, +/// so an account added to one later is held too. +#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Scope { + /// Every account on the server. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub server: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub accounts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub domains: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tenants: Vec, +} + +impl Scope { + pub fn is_empty(&self) -> bool { + !self.server + && self.accounts.is_empty() + && self.groups.is_empty() + && self.domains.is_empty() + && self.tenants.is_empty() + } + + /// Whether this scope covers everything `other` does, entry by entry. + /// A scope may only grow (LH-3's rule for ranges, applied to scope): + /// taking something out would free what it held. + pub fn contains(&self, other: &Scope) -> bool { + let all = |mine: &[u32], theirs: &[u32]| theirs.iter().all(|id| mine.contains(id)); + (self.server || !other.server) + && all(&self.accounts, &other.accounts) + && all(&self.groups, &other.groups) + && all(&self.domains, &other.domains) + && all(&self.tenants, &other.tenants) + } + + fn normalize(&mut self) { + for list in [ + &mut self.accounts, + &mut self.groups, + &mut self.domains, + &mut self.tenants, + ] { + list.sort_unstable(); + list.dedup(); + } + } +} + +/// What decides whether a hold's scope reaches an account: the domains of +/// its addresses, its groups and its tenant (LH-2). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Member { + pub account: u32, + pub domains: Vec, + pub groups: Vec, + pub tenant: Option, +} + +impl Member { + /// A person's account as the registry stores it; `None` for a group, + /// whose own data is held through its members. + pub fn of(account_id: u32, object: &ObjectInner) -> Option { + let ObjectInner::Account(Account::User(user)) = object else { + return None; + }; + let mut domains = vec![user.domain_id.document_id()]; + domains.extend(user.aliases.iter().map(|alias| alias.domain_id.document_id())); + domains.sort_unstable(); + domains.dedup(); + Some(Member { + account: account_id, + domains, + groups: user.member_group_ids.iter().map(|id| id.document_id()).collect(), + tenant: user.member_tenant_id.map(|id| id.document_id()), + }) + } +} + +impl Scope { + /// Whether this scope reaches `member`, directly or through its domains, + /// groups or tenant, as they are now (LH-2). + pub fn covers(&self, member: &Member) -> bool { + self.server + || self.accounts.contains(&member.account) + || member.domains.iter().any(|d| self.domains.contains(d)) + || member.groups.iter().any(|g| self.groups.contains(g)) + || member.tenant.is_some_and(|t| self.tenants.contains(&t)) + } +} + +/// When and why a hold was released (LH-10). +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Release { + pub at: u64, + pub by: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub by_id: Option, + pub reason: String, +} + +/// A legal hold (LH-1). +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hold { + pub id: u32, + /// The case name. + pub name: String, + /// A matter or ticket number. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub scope: Scope, + /// Seconds since the epoch. Items dated before aren't held (LH-3). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from: Option, + /// Seconds since the epoch. Items dated after aren't held (LH-3). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + pub placed_at: u64, + pub placed_by: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placed_by_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub released: Option, +} + +/// Why a change to a hold is refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// A released hold is read-only (LH-1). + Released, + /// The range may only widen (LH-3). + Narrowed, + /// The scope may only grow. + ScopeShrunk, + /// A hold has to cover something. + EmptyScope, + /// `from` after `to`. + Backwards, +} + +impl Refusal { + pub fn describe(self) -> &'static str { + match self { + Refusal::Released => "A released hold can't be changed; place a new one instead.", + Refusal::Narrowed => { + "A hold's date range can only be widened. To hold less, release it and place a new hold." + } + Refusal::ScopeShrunk => { + "Nothing can be taken out of a hold's scope. To hold less, release it and place a new hold." + } + Refusal::EmptyScope => "A hold has to cover at least one account, group, domain or tenant, or the whole server.", + Refusal::Backwards => "The range starts after it ends.", + } + } +} + +impl Hold { + pub fn is_active(&self) -> bool { + self.released.is_none() + } + + /// Whether an item dated `at` (seconds) falls in the hold's range. With + /// no range, everything does (LH-3). + pub fn covers_date(&self, at: u64) -> bool { + self.from.is_none_or(|from| at >= from) && self.to.is_none_or(|to| at <= to) + } + + /// Checks a new hold, and tidies its scope. + pub fn check_new(&mut self) -> Result<(), Refusal> { + self.scope.normalize(); + if self.scope.is_empty() { + return Err(Refusal::EmptyScope); + } + if let (Some(from), Some(to)) = (self.from, self.to) + && from > to + { + return Err(Refusal::Backwards); + } + Ok(()) + } + + /// Checks that `next` is an allowed change of `self`: names and notes + /// may change, the range may only widen, the scope may only grow, and a + /// released hold may not change at all. + pub fn check_update(&self, next: &mut Hold) -> Result<(), Refusal> { + if !self.is_active() { + return Err(Refusal::Released); + } + next.check_new()?; + // An open end can't be closed, and a set end can only move outward + let from_ok = match (self.from, next.from) { + (None, Some(_)) => false, + (Some(old), Some(new)) => new <= old, + (_, None) => true, + }; + let to_ok = match (self.to, next.to) { + (None, Some(_)) => false, + (Some(old), Some(new)) => new >= old, + (_, None) => true, + }; + if !from_ok || !to_ok { + return Err(Refusal::Narrowed); + } + if !next.scope.contains(&self.scope) { + return Err(Refusal::ScopeShrunk); + } + Ok(()) + } +} + +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 legal hold") + .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 legal hold") + .reason(err) + }) + } +} + +fn class(id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.push(FEATURE); + key.push(KIND_HOLD); + key.extend_from_slice(&id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(id: u32) -> ValueKey { + ValueKey::from(class(id)) +} + +fn original_class(item_id: u64) -> ValueClass { + let mut key = Vec::with_capacity(10); + key.push(FEATURE); + key.push(KIND_ORIGINAL); + key.extend_from_slice(&item_id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +/// LH-10: an archived item's deadline from before a hold froze it, so a +/// release can give it back (or a later one). None for an item held from +/// its deletion, which never had one. +pub async fn original_deadline(data: &Store, item_id: u64) -> trc::Result> { + data.get_value::(ValueKey::from(original_class(item_id))) + .await + .caused_by(trc::location!()) +} + +/// Notes (`Some`) or forgets (`None`) an item's deadline from before it +/// was frozen. +pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + match until { + Some(until) => batch.set(original_class(item_id), until.to_be_bytes().to_vec()), + None => batch.clear(original_class(item_id)), + }; + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// One hold, released or not. +pub async fn get(data: &Store, id: u32) -> trc::Result> { + Ok(data + .get_value::>(key(id)) + .await + .caused_by(trc::location!())? + .map(|Json(hold)| hold)) +} + +/// Every hold, released ones included, oldest first. +pub async fn all(data: &Store) -> trc::Result> { + let mut holds = Vec::new(); + data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| { + if let Ok(Json(hold)) = Json::::deserialize(value) { + holds.push(hold); + } + Ok(true) + }) + .await + .caused_by(trc::location!())?; + Ok(holds) +} + +/// The holds still in force. +pub async fn active(data: &Store) -> trc::Result> { + Ok(all(data).await?.into_iter().filter(Hold::is_active).collect()) +} + +/// Writes a new hold under the next free id, which it returns. Two nodes +/// placing holds at once can't take the same id: the key must be absent. +pub async fn create(data: &Store, hold: &Hold) -> trc::Result { + let mut attempt = 0; + loop { + attempt += 1; + let id = all(data).await?.iter().map(|h| h.id).max().unwrap_or(0) + 1; + let stored = Hold { + id, + ..hold.clone() + }; + let mut batch = BatchBuilder::new(); + batch.assert_value(class(id), AssertValue::None); + batch.set(class(id), Json(&stored).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => return Ok(id), + Err(err) + if attempt < CREATE_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => {} + Err(err) => return Err(err.caused_by(trc::location!())), + } + } +} + +/// The active holds that reach `member` (LH-2, LH-11). +pub async fn covering(data: &Store, member: &Member) -> trc::Result> { + Ok(active(data) + .await? + .into_iter() + .filter(|hold| hold.scope.covers(member)) + .collect()) +} + +/// LH-2: an account a hold reached through its domain, group or tenant stays +/// held when it leaves them: it is added to the hold by name. Called for +/// every change to an account, so no move escapes a hold. +pub async fn keep_moved(data: &Store, before: &Member, after: &Member) -> trc::Result<()> { + if before == after { + return Ok(()); + } + for mut hold in active(data).await? { + if hold.scope.covers(before) && !hold.scope.covers(after) { + hold.scope.accounts.push(after.account); + hold.scope.accounts.sort_unstable(); + hold.scope.accounts.dedup(); + update(data, &hold).await?; + } + } + Ok(()) +} + +/// LH-8: names `account_id` in every hold that reaches it, so a deleted +/// account, no longer in any domain or tenant, stays held. +pub async fn pin_account(data: &Store, member: &Member) -> trc::Result<()> { + for mut hold in covering(data, member).await? { + if !hold.scope.accounts.contains(&member.account) { + hold.scope.accounts.push(member.account); + hold.scope.accounts.sort_unstable(); + update(data, &hold).await?; + } + } + Ok(()) +} + +/// Replaces a hold that `check_update` allowed. +pub async fn update(data: &Store, hold: &Hold) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set(class(hold.id), Json(hold).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hold(scope: Scope, from: Option, to: Option) -> Hold { + Hold { + id: 1, + name: "Matter 4411".into(), + reference: Some("4411".into()), + description: None, + scope, + from, + to, + placed_at: 10, + placed_by: "admin".into(), + placed_by_id: None, + released: None, + } + } + + fn accounts(ids: &[u32]) -> Scope { + Scope { + accounts: ids.to_vec(), + ..Default::default() + } + } + + #[test] + fn a_hold_needs_a_scope_and_a_forward_range() { + assert_eq!(hold(Scope::default(), None, None).check_new(), Err(Refusal::EmptyScope)); + assert_eq!(hold(accounts(&[2]), Some(20), Some(10)).check_new(), Err(Refusal::Backwards)); + let mut ok = hold(accounts(&[3, 2, 3]), None, None); + assert_eq!(ok.check_new(), Ok(())); + assert_eq!(ok.scope.accounts, vec![2, 3], "sorted, once each"); + } + + #[test] + fn the_range_only_widens() { + let current = hold(accounts(&[2]), Some(100), Some(200)); + let widened = |from, to| { + let mut next = hold(accounts(&[2]), from, to); + current.check_update(&mut next) + }; + assert_eq!(widened(Some(50), Some(300)), Ok(())); + assert_eq!(widened(None, None), Ok(()), "opening both ends widens"); + assert_eq!(widened(Some(150), Some(200)), Err(Refusal::Narrowed)); + assert_eq!(widened(Some(100), Some(150)), Err(Refusal::Narrowed)); + + let open = hold(accounts(&[2]), None, None); + let mut closed = hold(accounts(&[2]), Some(1), None); + assert_eq!(open.check_update(&mut closed), Err(Refusal::Narrowed), "an open end stays open"); + } + + #[test] + fn the_scope_only_grows() { + let current = hold( + Scope { + accounts: vec![2], + domains: vec![7], + ..Default::default() + }, + None, + None, + ); + let mut grown = hold( + Scope { + accounts: vec![2, 3], + domains: vec![7], + tenants: vec![1], + ..Default::default() + }, + None, + None, + ); + assert_eq!(current.check_update(&mut grown), Ok(())); + let mut shrunk = hold(accounts(&[2, 3]), None, None); + assert_eq!(current.check_update(&mut shrunk), Err(Refusal::ScopeShrunk)); + + let server = hold(Scope { server: true, ..Default::default() }, None, None); + let mut less = hold(accounts(&[2]), None, None); + assert_eq!(server.check_update(&mut less), Err(Refusal::ScopeShrunk)); + } + + #[test] + fn a_released_hold_is_read_only() { + let mut released = hold(accounts(&[2]), None, None); + released.released = Some(Release { + at: 50, + by: "admin".into(), + by_id: None, + reason: "Settled".into(), + }); + let mut next = released.clone(); + next.name = "Renamed".into(); + assert_eq!(released.check_update(&mut next), Err(Refusal::Released)); + assert!(!released.is_active()); + } + + #[test] + fn dates_in_range() { + let whole = hold(accounts(&[2]), None, None); + assert!(whole.covers_date(0) && whole.covers_date(u64::MAX)); + let ranged = hold(accounts(&[2]), Some(100), Some(200)); + assert!(ranged.covers_date(100) && ranged.covers_date(200)); + assert!(!ranged.covers_date(99) && !ranged.covers_date(201)); + let open_ended = hold(accounts(&[2]), Some(100), None); + assert!(open_ended.covers_date(u64::MAX), "no `to` also catches mail still to come"); + } + + #[test] + fn a_scope_reaches_members_through_domain_group_and_tenant() { + let member = Member { + account: 9, + domains: vec![3, 4], + groups: vec![20], + tenant: Some(7), + }; + let reaches = |scope: Scope| scope.covers(&member); + assert!(reaches(accounts(&[9]))); + assert!(reaches(Scope { domains: vec![4], ..Default::default() }), "an alias's domain counts"); + assert!(reaches(Scope { groups: vec![20], ..Default::default() })); + assert!(reaches(Scope { tenants: vec![7], ..Default::default() })); + assert!(reaches(Scope { server: true, ..Default::default() })); + assert!(!reaches(Scope { domains: vec![5], tenants: vec![8], ..Default::default() })); + + // LH-2: leaving the held domain would free it, so the hold must name it + let held = hold(Scope { domains: vec![3], ..Default::default() }, None, None); + let moved = Member { domains: vec![6], ..member.clone() }; + assert!(held.scope.covers(&member) && !held.scope.covers(&moved)); + } + + #[test] + fn keeping_deleted_items() { + let whole = Keeping::new(None, &[hold(accounts(&[2]), None, None)]); + assert!(whole.covers(Some(5)) && whole.covers(None)); + assert_eq!(whole.until(100, whole.covers(Some(5))), Some(HELD_UNTIL)); + assert!(is_held_until(whole.until(100, true).unwrap())); + + // LH-3: a range holds only what's inside it; outside, undelete's rules + let ranged = Keeping::new(Some(30), &[hold(accounts(&[2]), Some(1_000), Some(2_000))]); + assert!(ranged.covers(Some(1_500)) && !ranged.covers(Some(2_500))); + assert!(ranged.covers(None), "contacts, files and scripts are held whole"); + assert_eq!(ranged.until(100, ranged.covers(Some(2_500))), Some(130)); + assert!(ranged.covers_event(Some(2_000 + 3_600)), "a day of slack for an event"); + + // Neither held nor undelete: nothing is kept + let none = Keeping::new(None, &[]); + assert!(!none.keeps_anything()); + assert_eq!(none.until(100, false), None); + assert!(!is_held_until(100 + 30 * 365 * 86_400)); + } + + #[test] + fn stored_as_json() { + let current = hold(accounts(&[2]), Some(100), None); + let json = serde_json::to_string(¤t).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), current); + assert!(json.contains("\"scope\":{\"accounts\":[2]}"), "{json}"); + } +} diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 617ec59..7e74456 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 hold; pub mod lock; pub mod masked_email; pub mod security; diff --git a/crates/features/src/lock/mod.rs b/crates/features/src/lock/mod.rs index d44a26d..3d875c4 100644 --- a/crates/features/src/lock/mod.rs +++ b/crates/features/src/lock/mod.rs @@ -27,6 +27,11 @@ use store::{ write::{AnyClass, BatchBuilder, ValueClass}, }; use trc::AddContext; +use types::{ + acl::{Acl, AclGrant}, + collection::Collection, +}; +use utils::map::bitmap::Bitmap; /// Rung when a lock is written, so this node's expiry timer re-reads the /// `until` dates (AL-5): a delegation ends at its time, not at a sweep. @@ -53,11 +58,6 @@ pub fn ended_between(locks: &[Lock], after: u64, now: u64) -> impl Iterator, pub keywords: Vec, + /// LH-3: the ranges of the holds on the account when it was deleted. + /// Its received date is only known when it's archived, which decides + /// whether a hold keeps it after all. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub held_ranges: Vec<(Option, Option)>, + /// The undelete deadline for when no range covers it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub otherwise_until: Option, } /// What restore needs beyond the kept copy (UD-4, UD-8). @@ -462,8 +470,15 @@ mod tests { size: 3, mailboxes: vec![1], keywords: vec![], + held_ranges: vec![(Some(10), None)], + otherwise_until: Some(20), }; let bytes = Json(¬e).serialize().unwrap(); assert_eq!(Json::::deserialize(&bytes).unwrap().0, note); + + // A note written before legal holds still reads, as not held + let old = br#"{"archived_at":1,"archived_until":2,"size":3,"mailboxes":[1],"keywords":[]}"#; + let read = Json::::deserialize(old).unwrap().0; + assert!(read.held_ranges.is_empty() && read.otherwise_until.is_none()); } } diff --git a/crates/features/src/undelete/email.rs b/crates/features/src/undelete/email.rs index 83cc244..7d67380 100644 --- a/crates/features/src/undelete/email.rs +++ b/crates/features/src/undelete/email.rs @@ -12,9 +12,12 @@ //! is made if archiving is on, fixing the deadline then. When the data is //! finally removed, a noted message becomes an archived item. -use crate::undelete::{ - data::{self, EmailNote, Extra}, - records, +use crate::{ + hold::Keeping, + undelete::{ + data::{self, EmailNote, Extra}, + records, + }, }; use registry::{ schema::structs::{ArchivedEmail, ArchivedItem}, @@ -26,10 +29,12 @@ use store::{ }; use types::{blob::BlobId, blob_hash::BlobHash}; -/// Notes a deleted message, when archiving is on (`retention` seconds). +/// Notes a deleted message, when anything keeps it: undelete, or a legal +/// hold on the account (LH-4). A held note keeps it until it's archived, +/// when its received date says whether the hold's range covers it. pub fn note( batch: &mut BatchBuilder, - retention: u64, + keeping: &Keeping, account_id: u32, document_id: u32, size: u64, @@ -37,16 +42,23 @@ pub fn note( keywords: Vec, ) -> trc::Result<()> { let archived_at = now(); + // Held until the date is known; the undelete deadline otherwise + let otherwise_until = keeping.until(archived_at, false); + let Some(archived_until) = keeping.until(archived_at, keeping.is_held()) else { + return Ok(()); + }; data::note_email( batch, account_id, document_id, &EmailNote { archived_at, - archived_until: archived_at + retention, + archived_until, size, mailboxes, keywords, + held_ranges: keeping.ranges.clone(), + otherwise_until: if keeping.is_held() { otherwise_until } else { None }, }, ) } @@ -78,9 +90,27 @@ pub async fn archive( document_id: u32, summary: Summary<'_>, ) -> trc::Result { - let Some(note) = data::email_note(data, account_id, document_id).await? else { + let Some(mut note) = data::email_note(data, account_id, document_id).await? else { return Ok(false); }; + // LH-3: a held note's range decides now that the date is known; outside + // it, undelete's deadline, or nothing kept at all + if !note.held_ranges.is_empty() { + let keeping = Keeping { + retention: None, + ranges: std::mem::take(&mut note.held_ranges), + }; + if !keeping.covers(Some(summary.received_at)) { + match note.otherwise_until { + Some(until) => note.archived_until = until, + None => { + let mut batch = BatchBuilder::new(); + data::clear_email_note(&mut batch, account_id, document_id); + return data.write(batch.build_all()).await.map(|_| false); + } + } + } + } let item = ArchivedItem::Email(ArchivedEmail { from: summary.from.unwrap_or_default().to_string(), subject: summary.subject.unwrap_or_default().to_string(), diff --git a/crates/features/src/undelete/groupware.rs b/crates/features/src/undelete/groupware.rs index b94e7dc..a77eb0f 100644 --- a/crates/features/src/undelete/groupware.rs +++ b/crates/features/src/undelete/groupware.rs @@ -97,6 +97,39 @@ pub async fn take( Ok(Some(note)) } +/// A note, left in place: for a held account it's cleared only once its item +/// is archived, so a failure leaves it for the retry (LH-5). +pub async fn peek( + data: &Store, + kind: Kind, + account_id: u32, + document_id: u32, +) -> trc::Result> { + Ok(data + .get_value::>(ValueKey::from(note_class(kind, account_id, document_id))) + .await? + .map(|Json(note)| note)) +} + +/// Removes a note once its item is archived or needn't be. +pub async fn clear(data: &Store, kind: Kind, account_id: u32, document_id: u32) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.clear(note_class(kind, account_id, document_id)); + data.write(batch.build_all()).await.map(|_| ()) +} + +/// An event's start, for a hold's range (LH-3). None for a recurring event, +/// which may have an occurrence anywhere, so a hold keeps it whole. +pub fn event_start(note: &Note) -> Option { + let text = note.content.as_deref()?; + if property(text, "RRULE").is_some() || property(text, "RDATE").is_some() { + return None; + } + property(text, "DTSTART") + .and_then(|v| ical_time(&v)) + .map(|t| t.max(0) as u64) +} + /// The value of the first line starting with `name` (as `NAME:` or /// `NAME;params:`) in iCalendar or vCard text, unfolded. fn property(text: &str, name: &str) -> Option { diff --git a/crates/features/src/undelete/records.rs b/crates/features/src/undelete/records.rs index eb2b410..e21bdc8 100644 --- a/crates/features/src/undelete/records.rs +++ b/crates/features/src/undelete/records.rs @@ -89,6 +89,54 @@ pub async fn insert( Ok(id) } +/// Moves an archived item's deadline, and its kept copy's with it: frozen +/// by a hold (LH-6) or given a real one on release (LH-10). Returns the +/// item as it now is. +pub async fn set_deadline( + data: &Store, + registry: &RegistryStore, + id: Id, + item: &ArchivedItem, + until: u64, +) -> trc::Result { + let account_id = item.account_id().document_id(); + let blob_hash = item.blob_id().hash.clone(); + let before = item.archived_until().timestamp() as u64; + let mut updated = item.clone(); + updated.set_archived_until(registry::types::datetime::UTCDateTime::from_timestamp(until as i64)); + + // The new link first, so the kept copy is never unlinked in between + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .set( + BlobOp::Link { + hash: blob_hash.clone(), + to: BlobLink::Temporary { until }, + }, + vec![], + ); + if before != until { + batch.clear(BlobOp::Link { + hash: blob_hash, + to: BlobLink::Temporary { until: before }, + }); + } + data::log_change(&mut batch, account_id, registry.assign_id(), id, Change::Updated); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + let mut batch = BatchBuilder::new(); + batch.set(item_class(id.id()), updated.to_pickled_vec()); + registry + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(updated) +} + /// Removes an archived item and releases its kept copy: on restore (UD-9), /// on destroy (UD-12) and past its deadline (UD-13). pub async fn remove( @@ -184,15 +232,38 @@ pub async fn get( } } +/// Every archived item on the server, account by account. Items are +/// indexed by account only, so the registry's query without a filter, +/// which reads its all-ids index, finds none of them. +pub async fn all(data: &Store, registry: &RegistryStore) -> trc::Result> { + let mut accounts = registry + .query::>(RegistryQuery::new(ObjectType::Account)) + .await + .caused_by(trc::location!())? + .into_iter() + .map(|id| id.document_id()) + .collect::>(); + // Deleted accounts still kept have archived items too + accounts.extend(data::kept_accounts(data).await?.into_iter().map(|(id, _)| id)); + accounts.sort_unstable(); + accounts.dedup(); + let mut items = Vec::new(); + for account_id in accounts { + items.extend( + registry + .query::>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id)) + .await + .caused_by(trc::location!())?, + ); + } + Ok(items) +} + /// Removes every expired archived item on the server (UD-13), for the /// scheduled clean-up. pub async fn remove_expired(data: &Store, registry: &RegistryStore) -> trc::Result { let mut removed = 0; - for id in registry - .query::>(RegistryQuery::new(ObjectType::ArchivedItem)) - .await - .caused_by(trc::location!())? - { + for id in all(data, registry).await? { if let Some(item) = registry.object::(id).await? && is_expired(&item) { diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs index 918f177..0a8aebb 100644 --- a/crates/imap/src/op/expunge.rs +++ b/crates/imap/src/op/expunge.rs @@ -243,10 +243,8 @@ impl SessionData { let mut fully_deleted = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new(); - // inbuxa: UD-1, UD-6a: the retention in force now - let retention = inbuxa_features::undelete::settings::retention(self.server.registry()) - .await? - .items; + // inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept + let keeping = self.server.keeping(account_id).await?; self.server .archives( account_id, @@ -270,10 +268,10 @@ impl SessionData { fully_deleted.insert(document_id); thread_ids.insert(metadata.inner.thread_id.to_native()); // inbuxa: UD-1, UD-4: a deleted message is noted for archiving - if let Some(retention) = retention { + if keeping.keeps_anything() { inbuxa_features::undelete::email::note( batch, - retention, + &keeping, account_id, document_id, metadata.inner.size.to_native() as u64, diff --git a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs new file mode 100644 index 0000000..aa2b1fc --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:LegalHold/get` and `/set` under `urn:inbuxa:jmap`: legal holds +//! (audit-hold-lock spec, LH-1 to LH-14). Creating one places the hold; +//! updating renames it, widens its range or scope, or releases it with +//! `released: true`. There is no destroy: a released hold stays listed. The +//! set call's `reason` argument says why, for the audit log (AU-12); +//! creating takes it as a property too. + +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 LegalHold; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LegalHoldProperty { + Id, + /// The case name. + Name, + /// A matter or ticket number. + Reference, + Description, + /// `{server, accounts, groups, domains, tenants}`. + Scope, + /// The range's start, a UTC date, or null. + From, + /// The range's end, a UTC date, or null. + To, + /// Why it was placed (create only; later reasons are the audit log's). + Reason, + PlacedAt, + PlacedBy, + /// Set to true to release it. + Released, + ReleasedAt, + ReleasedBy, + ReleaseReason, + /// LH-9: accounts it covers now, deleted ones it keeps included. + AccountsCovered, + /// LH-9: archived items it keeps, and their size in bytes. + ItemsHeld, + SizeHeld, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LegalHoldValue { + Id(Id), +} + +impl Property for LegalHoldProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside the scope stay plain keys + match parent { + None => LegalHoldProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + LegalHoldProperty::Id => "id", + LegalHoldProperty::Name => "name", + LegalHoldProperty::Reference => "reference", + LegalHoldProperty::Description => "description", + LegalHoldProperty::Scope => "scope", + LegalHoldProperty::From => "from", + LegalHoldProperty::To => "to", + LegalHoldProperty::Reason => "reason", + LegalHoldProperty::PlacedAt => "placedAt", + LegalHoldProperty::PlacedBy => "placedBy", + LegalHoldProperty::Released => "released", + LegalHoldProperty::ReleasedAt => "releasedAt", + LegalHoldProperty::ReleasedBy => "releasedBy", + LegalHoldProperty::ReleaseReason => "releaseReason", + LegalHoldProperty::AccountsCovered => "accountsCovered", + LegalHoldProperty::ItemsHeld => "itemsHeld", + LegalHoldProperty::SizeHeld => "sizeHeld", + } + .into() + } +} + +impl LegalHoldProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => LegalHoldProperty::Id, + b"name" => LegalHoldProperty::Name, + b"reference" => LegalHoldProperty::Reference, + b"description" => LegalHoldProperty::Description, + b"scope" => LegalHoldProperty::Scope, + b"from" => LegalHoldProperty::From, + b"to" => LegalHoldProperty::To, + b"reason" => LegalHoldProperty::Reason, + b"placedAt" => LegalHoldProperty::PlacedAt, + b"placedBy" => LegalHoldProperty::PlacedBy, + b"released" => LegalHoldProperty::Released, + b"releasedAt" => LegalHoldProperty::ReleasedAt, + b"releasedBy" => LegalHoldProperty::ReleasedBy, + b"releaseReason" => LegalHoldProperty::ReleaseReason, + b"accountsCovered" => LegalHoldProperty::AccountsCovered, + b"itemsHeld" => LegalHoldProperty::ItemsHeld, + b"sizeHeld" => LegalHoldProperty::SizeHeld, + ) + } +} + +impl FromStr for LegalHoldProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + LegalHoldProperty::parse(s).ok_or(()) + } +} + +impl Element for LegalHoldValue { + type Property = LegalHoldProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(LegalHoldProperty::Id) => Id::from_str(value).ok().map(LegalHoldValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + LegalHoldValue::Id(id) => id.to_string().into(), + } + } +} + +/// The get call's own argument: only the active holds covering an account, +/// through any route (LH-2), for the console's Held badge (LH-14). +#[derive(Debug, Clone, Default)] +pub struct LegalHoldGetArguments { + pub covering_account: Option, +} + +impl<'de> DeserializeArguments<'de> for LegalHoldGetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "coveringAccount" { + self.covering_account = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +/// The set call's own arguments: why (AU-12). +#[derive(Debug, Clone, Default)] +pub struct LegalHoldSetArguments { + pub reason: Option, +} + +impl<'de> DeserializeArguments<'de> for LegalHoldSetArguments { + 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 LegalHold { + type Property = LegalHoldProperty; + + type Element = LegalHoldValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = LegalHoldGetArguments; + + type SetArguments<'de> = LegalHoldSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = LegalHoldProperty::Id; +} + +impl From for LegalHoldValue { + fn from(id: Id) -> Self { + LegalHoldValue::Id(id) + } +} + +impl JmapObjectId for LegalHoldValue { + fn as_id(&self) -> Option { + match self { + LegalHoldValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + LegalHoldValue::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 = LegalHoldValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for LegalHoldProperty { + 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 e838bb8..c9c537d 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -24,6 +24,7 @@ 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_legal_hold; // inbuxa: legal hold pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 5d78608..0462c89 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -70,6 +70,9 @@ impl Response<'_> { GetResponseMethod::AccountLock(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::LegalHold(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 633231e..ca391d9 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -49,6 +49,7 @@ impl Response<'_> { GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?, GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, + GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::TenantProtocolPolicy(request) => { request.resolve_references(self)? @@ -111,6 +112,9 @@ impl Response<'_> { SetRequestMethod::AccountLock(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::LegalHold(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::ProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index b404836..d23af91 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -58,6 +58,8 @@ pub enum MethodObject { AuditVerification, // inbuxa: account lock with delegation AccountLock, + // inbuxa: legal hold + LegalHold, ProtocolPolicy, TenantProtocolPolicy, } @@ -91,7 +93,8 @@ impl MethodObject { | MethodObject::AuditSettings | MethodObject::AuditExport | MethodObject::AuditVerification - | MethodObject::AccountLock => Capability::Inbuxa, + | MethodObject::AccountLock + | MethodObject::LegalHold => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -279,6 +282,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set", (MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get", (MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set", + (MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get", + (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", (MethodFunction::Set, MethodObject::AuditVerification) => { "inbuxa:AuditVerification/set" } @@ -423,6 +428,8 @@ impl MethodName { "inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set), "inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get), "inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set), + "inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get), + "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), "inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set), "inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), @@ -487,6 +494,7 @@ impl Display for MethodObject { MethodObject::AuditExport => "inbuxa:AuditExport", MethodObject::AuditVerification => "inbuxa:AuditVerification", MethodObject::AccountLock => "inbuxa:AccountLock", + MethodObject::LegalHold => "inbuxa:LegalHold", 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 47e3ee2..6f24964 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -119,6 +119,7 @@ pub enum GetRequestMethod { AuditEvent(Box>), AuditSettings(Box>), AccountLock(Box>), + LegalHold(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, @@ -151,6 +152,7 @@ pub enum SetRequestMethod<'x> { AuditExport(Box>), AuditVerification(Box>), AccountLock(Box>), + LegalHold(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index a909ddf..8de167e 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -566,6 +566,21 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: legal hold + (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::LegalHold) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LegalHold(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 1fe6925..b340c5e 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -106,6 +106,7 @@ pub enum GetResponseMethod { AuditEvent(GetResponse), AuditSettings(GetResponse), AccountLock(GetResponse), + LegalHold(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( GetResponse, @@ -138,6 +139,7 @@ pub enum SetResponseMethod { AuditExport(Box>), AuditVerification(Box>), AccountLock(Box>), + LegalHold(Box>), Explanation(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -765,3 +767,16 @@ impl<'x> From> for ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value))) } } + +// inbuxa: legal hold +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::LegalHold(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value))) + } +} diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index eb26e5e..520f909 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -96,6 +96,7 @@ impl JmapAuthorization for AccessToken { } // inbuxa: account lock (AL-12) GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet, + GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -222,6 +223,15 @@ impl JmapAuthorization for AccessToken { Permission::SysAccountLockUpdate, Permission::SysAccountLockDestroy, ), + // inbuxa: legal hold (LH-13); holds are never destroyed, + // and the handler refuses a destroy outright + SetRequestMethod::LegalHold(s) => validate_set( + s, + self, + Permission::SysLegalHoldCreate, + Permission::SysLegalHoldUpdate, + Permission::SysLegalHoldUpdate, + ), SetRequestMethod::AuditVerification(s) => validate_set( s, self, @@ -369,6 +379,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::AuditExport | MethodObject::AuditVerification | MethodObject::AccountLock + | MethodObject::LegalHold | 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 e2803f4..a347b86 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -273,6 +273,9 @@ impl RequestHandler for Server { SetResponseMethod::AccountLock(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::LegalHold(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::Explanation(set_response) => { set_response.update_created_ids(&mut response); } @@ -446,6 +449,11 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: legal hold (LH-1) + GetRequestMethod::LegalHold(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::legal_hold::get(self, *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)?; @@ -797,6 +805,34 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: legal hold (LH-1), each change recorded with its + // reason (AU-12) + SetRequestMethod::LegalHold(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::legal_hold::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) diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 537cd0c..7707627 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -424,6 +424,7 @@ impl IntermediateChangesResponse { | MethodObject::AuditExport | MethodObject::AuditVerification | MethodObject::AccountLock + | MethodObject::LegalHold | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index 2fc61fa..7e397a3 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -167,7 +167,8 @@ async fn before( for (client_id, value) in request.create.iter().flat_map(|c| c.iter()) { let after = serde_json::to_value(value).unwrap_or_default(); - let described = diff::describe(&after); + let mut described = diff::describe(&after); + described.name = full_name(server, object, &after, described.name).await; let changes = after .as_object() .map(|patch| diff::patch(object, None, patch)) @@ -192,7 +193,10 @@ async fn before( None => fork_current(server, object, id).await, }; let patch = serde_json::to_value(value).unwrap_or_default(); - let described = before.as_ref().map(diff::describe).unwrap_or_default(); + let mut described = before.as_ref().map(diff::describe).unwrap_or_default(); + if let Some(before) = &before { + described.name = full_name(server, object, before, described.name).await; + } let changes = patch .as_object() .map(|patch| diff::patch(object, before.as_ref(), patch)) @@ -214,7 +218,10 @@ async fn before( if let Some(MaybeResultReference::Value(destroy)) = &request.destroy { for id in destroy { let before = stored(server, registry, id).await; - let described = before.as_ref().map(diff::describe).unwrap_or_default(); + let mut described = before.as_ref().map(diff::describe).unwrap_or_default(); + if let Some(before) = &before { + described.name = full_name(server, object, before, described.name).await; + } records.push(( Item::Destroy(id.clone()), Action::Destroy, @@ -345,6 +352,29 @@ fn id_text(id: &MaybeInvalid) -> String { /// The fork's own settings as they are now, as JSON, so their changes are /// recorded with what they replaced. Their stored names are the JMAP /// property names. +/// An account's or a mailing list's name is only its local part, and two +/// domains' "leslie" would read alike: records name it by its full address. +async fn full_name(server: &Server, object: &str, value: &Value, name: Option) -> Option { + let name = name?; + if !matches!(object, "x:Account" | "x:MailingList") || name.contains('@') { + return Some(name); + } + let domain = value + .get("domainId") + .and_then(Value::as_str) + .and_then(|id| ::from_str(id).ok()); + match domain { + Some(domain) => match server.domain_by_id(domain.document_id()).await { + Ok(Some(domain)) => match domain.names.first() { + Some(domain) => Some(format!("{name}@{domain}")), + None => Some(name), + }, + _ => Some(name), + }, + None => Some(name), + } +} + async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid) -> Option { use inbuxa_features::{ai::limits, audit::log, security}; let data = server.store(); @@ -361,6 +391,34 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid) -> O .await .ok() .and_then(|policy| serde_json::to_value(policy).ok()), + // LH-1: a hold as the API shows it, so a change reads before/after + "inbuxa:LegalHold" => match id { + MaybeInvalid::Value(id) => { + let hold = inbuxa_features::hold::get(data, u32::try_from(id.id()).ok()?) + .await + .ok()??; + let ids = |list: &[u32]| list.iter().map(|id| Id::from(*id).to_string()).collect::>(); + let date = |at: Option| { + at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at as i64).to_string()) + }; + Some(serde_json::json!({ + "name": hold.name, + "reference": hold.reference, + "description": hold.description, + "scope": { + "server": hold.scope.server, + "accounts": ids(&hold.scope.accounts), + "groups": ids(&hold.scope.groups), + "domains": ids(&hold.scope.domains), + "tenants": ids(&hold.scope.tenants), + }, + "from": date(hold.from), + "to": date(hold.to), + "released": !hold.is_active(), + })) + } + MaybeInvalid::Invalid(_) => None, + }, "inbuxa:TenantProtocolPolicy" => match id { MaybeInvalid::Value(id) => { security::tenant_protocol_policy::get(data, id.document_id()) diff --git a/crates/jmap/src/inbuxa/deleted_account.rs b/crates/jmap/src/inbuxa/deleted_account.rs index a635bae..bbfb07c 100644 --- a/crates/jmap/src/inbuxa/deleted_account.rs +++ b/crates/jmap/src/inbuxa/deleted_account.rs @@ -124,13 +124,29 @@ pub async fn reserved( /// of upstream's immediate destruction. Returns the other accounts whose /// access changed, or `None` when nothing is kept. pub async fn keep(server: &Server, id: Id, account: &Account) -> trc::Result>> { - let Some(period) = retention(server.registry()).await?.accounts else { - return Ok(None); - }; let account_id = id.document_id(); - let deleted_at = now(); - let kept_until = deleted_at + period; let inner = ObjectInner::Account(account.clone()); + // inbuxa: LH-8: a held account's data stays, with no expiry, whether or + // not undelete keeps accounts; its holds name it from now on + let member = inbuxa_features::hold::Member::of(account_id, &inner); + let held = match &member { + Some(member) => { + !inbuxa_features::hold::covering(server.store(), member) + .await? + .is_empty() + } + None => false, + }; + let period = retention(server.registry()).await?.accounts; + let deleted_at = now(); + let kept_until = match (held, period) { + (true, _) => inbuxa_features::hold::HELD_UNTIL, + (false, Some(period)) => deleted_at + period, + (false, None) => return Ok(None), + }; + if held && let Some(member) = &member { + inbuxa_features::hold::pin_account(server.store(), member).await?; + } let addresses = addresses_of(server, &inner) .await? .into_iter() @@ -324,6 +340,18 @@ pub async fn set( for id in will_destroy { match data::kept_account(data, id.document_id()).await? { + // inbuxa: LH-8: a held account's data can't be destroyed + Some(kept) + if may_reach(access_token, &kept, Permission::SysAccountDestroy) + && (inbuxa_features::hold::is_held_until(kept.kept_until) + || server.is_kept_held(id.document_id(), &kept).await?) => + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("A legal hold applies to this account, so its data stays."), + ); + } Some(kept) if may_reach(access_token, &kept, Permission::SysAccountDestroy) => { destroy_now(server, id, &kept).await?; response.destroyed.push(id); diff --git a/crates/jmap/src/inbuxa/legal_hold.rs b/crates/jmap/src/inbuxa/legal_hold.rs new file mode 100644 index 0000000..3cce717 --- /dev/null +++ b/crates/jmap/src/inbuxa/legal_hold.rs @@ -0,0 +1,459 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:LegalHold` (audit-hold-lock spec, LH-1 to LH-14): placing, +//! widening and releasing holds. Only server-level administrators reach +//! this: the tenant ceiling strips the permissions from everyone in a +//! tenant (LH-13). What a hold keeps is the undelete hooks' job. + +use common::{Server, auth::AccessToken, hold::HoldSummary}; +use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_legal_hold::{ + LegalHold, LegalHoldProperty as P, LegalHoldSetArguments, LegalHoldValue, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use std::str::FromStr; +use store::write::now; +use types::id::Id; + +type LValue = Value<'static, P, LegalHoldValue>; + +const ALL: &[P] = &[ + P::Id, + P::Name, + P::Reference, + P::Description, + P::Scope, + P::From, + P::To, + P::PlacedAt, + P::PlacedBy, + P::Released, + P::ReleasedAt, + P::ReleasedBy, + P::ReleaseReason, +]; + +/// The longest a name, reference or description may be. +const MAX_TEXT: usize = 500; + +fn date(seconds: u64) -> LValue { + Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into()) +} + +fn text(value: &Option) -> LValue { + value + .as_ref() + .map_or(Value::Null, |v| Value::Str(v.clone().into())) +} + +fn ids(list: &[u32]) -> LValue { + Value::Array( + list.iter() + .map(|id| Value::Str(Id::from(*id).to_string().into())) + .collect(), + ) +} + +fn to_value(hold: &Hold, properties: &[P], summary: Option<&HoldSummary>) -> LValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(LegalHoldValue::Id(Id::from(hold.id))), + P::Name => Value::Str(hold.name.clone().into()), + P::Reference => text(&hold.reference), + P::Description => text(&hold.description), + P::Scope => { + let mut scope = Map::with_capacity(5); + scope.insert_unchecked(Key::Borrowed("server"), Value::Bool(hold.scope.server)); + scope.insert_unchecked(Key::Borrowed("accounts"), ids(&hold.scope.accounts)); + scope.insert_unchecked(Key::Borrowed("groups"), ids(&hold.scope.groups)); + scope.insert_unchecked(Key::Borrowed("domains"), ids(&hold.scope.domains)); + scope.insert_unchecked(Key::Borrowed("tenants"), ids(&hold.scope.tenants)); + Value::Object(scope) + } + P::From => hold.from.map_or(Value::Null, date), + P::To => hold.to.map_or(Value::Null, date), + P::Reason => Value::Null, + P::PlacedAt => date(hold.placed_at), + P::PlacedBy => Value::Str(hold.placed_by.clone().into()), + P::Released => Value::Bool(!hold.is_active()), + P::ReleasedAt => hold.released.as_ref().map_or(Value::Null, |r| date(r.at)), + P::ReleasedBy => hold + .released + .as_ref() + .map_or(Value::Null, |r| Value::Str(r.by.clone().into())), + P::ReleaseReason => hold + .released + .as_ref() + .map_or(Value::Null, |r| Value::Str(r.reason.clone().into())), + P::AccountsCovered => Value::Number(summary.map_or(0, |s| s.accounts).into()), + P::ItemsHeld => Value::Number(summary.map_or(0, |s| s.items).into()), + P::SizeHeld => Value::Number(summary.map_or(0, |s| s.size).into()), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:LegalHold/get`: every hold, released ones included (LH-1). +pub async fn get( + server: &Server, + 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(); + // LH-9: only when asked for, since it walks the archive + let summaries = if properties + .iter() + .any(|p| matches!(p, P::AccountsCovered | P::ItemsHeld | P::SizeHeld)) + { + server.hold_summaries().await? + } else { + Default::default() + }; + // LH-14: the holds on one account, whether it's live or deleted and kept + if let Some(account) = request.arguments.covering_account.take() { + let account_id = account.document_id(); + let covering = match server.member_of(account_id).await { + Some(member) => hold::covering(data, &member).await?, + None => match inbuxa_features::undelete::data::kept_account(data, account_id).await? { + Some(kept) => { + hold::covering(data, &common::hold::kept_member(account_id, &kept)).await? + } + None => Vec::new(), + }, + }; + for current in covering { + response + .list + .push(to_value(¤t, &properties, summaries.get(¤t.id))); + } + return Ok(response); + } + match ids { + None => { + for current in hold::all(data).await? { + response + .list + .push(to_value(¤t, &properties, summaries.get(¤t.id))); + } + } + Some(ids) => { + for id in ids { + match u32::try_from(id.id()) + .ok() + .map(|id| hold::get(data, id)) + { + Some(found) => match found.await? { + Some(current) => response.list.push(to_value( + ¤t, + &properties, + summaries.get(¤t.id), + )), + None => response.push_not_found(id), + }, + None => 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(MAX_TEXT).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.") +} + +fn refused(refusal: Refusal) -> SetError

{ + let property = match refusal { + Refusal::Released => P::Released, + Refusal::Narrowed | Refusal::Backwards => P::From, + Refusal::ScopeShrunk | Refusal::EmptyScope => P::Scope, + }; + SetError::invalid_properties() + .with_property(property) + .with_description(refusal.describe()) +} + +fn invalid(property: P, why: &str) -> SetError

{ + SetError::invalid_properties() + .with_property(property) + .with_description(why.to_string()) +} + +/// A text property: a string, trimmed and capped, or null for none. +fn parse_text( + property: P, + value: &Value<'_, P, LegalHoldValue>, + required: bool, +) -> Result, SetError

> { + match value { + Value::Str(s) => { + let s = s.trim(); + if s.is_empty() { + if required { + Err(invalid(property, "This can't be empty.")) + } else { + Ok(None) + } + } else { + Ok(Some(s.chars().take(MAX_TEXT).collect())) + } + } + Value::Null if !required => Ok(None), + _ => Err(invalid(property, "Expected text.")), + } +} + +fn parse_date(property: P, value: &Value<'_, P, LegalHoldValue>) -> Result, SetError

> { + match value { + Value::Null => Ok(None), + Value::Str(s) => UTCDate::from_str(s) + .ok() + .map(|d| Some(d.timestamp().max(0) as u64)) + .ok_or_else(|| invalid(property, "Expected a UTC date, or null.")), + _ => Err(invalid(property, "Expected a UTC date, or null.")), + } +} + +/// Reads a scope and checks that every account, group, domain and tenant +/// it names exists and is the right kind (LH-1). +async fn parse_scope(server: &Server, value: &Value<'_, P, LegalHoldValue>) -> Result> { + let Value::Object(map) = value else { + return Err(invalid(P::Scope, "Expected an object.")); + }; + let mut scope = Scope::default(); + for (key, value) in map.iter() { + let name: String = key.to_string().to_string(); + if name == "server" { + match value { + Value::Bool(b) => scope.server = *b, + _ => return Err(invalid(P::Scope, "`server` must be true or false.")), + } + continue; + } + let Value::Array(items) = value else { + return Err(invalid(P::Scope, &format!("`{name}` must be a list of ids."))); + }; + let mut list = Vec::with_capacity(items.len()); + for item in items { + let id = match item { + Value::Str(s) => Id::from_str(s).ok(), + Value::Element(LegalHoldValue::Id(id)) => Some(*id), + _ => None, + } + .and_then(|id| u32::try_from(id.id()).ok()) + .ok_or_else(|| invalid(P::Scope, &format!("`{name}` must be a list of ids.")))?; + list.push(id); + } + for id in &list { + let exists = match name.as_str() { + "accounts" => server.account(*id).await.is_ok_and(|a| a.is_user_account()), + "groups" => server.account(*id).await.is_ok_and(|a| !a.is_user_account()), + "domains" => server.domain_by_id(*id).await.ok().flatten().is_some(), + "tenants" => server.tenant(*id).await.is_ok(), + _ => return Err(invalid(P::Scope, &format!("Unknown scope entry `{name}`."))), + }; + if !exists { + return Err(invalid( + P::Scope, + &format!("No such {} as {}.", name.trim_end_matches('s'), Id::from(*id)), + )); + } + } + match name.as_str() { + "accounts" => scope.accounts = list, + "groups" => scope.groups = list, + "domains" => scope.domains = list, + _ => scope.tenants = list, + } + } + Ok(scope) +} + +/// `inbuxa:LegalHold/set`: create places a hold; update renames it, widens +/// its range or scope, or releases it; destroy is refused (LH-13). The +/// request layer records each, with its reason. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, LegalHold>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let arguments: LegalHoldSetArguments = std::mem::take(&mut request.arguments); + let data = server.store(); + let actor = server.audit_actor(access_token).await; + + 'create: for (client_id, value) in request.unwrap_create() { + let mut new = Hold { + id: 0, + name: String::new(), + reference: None, + description: None, + scope: Scope::default(), + from: None, + to: None, + placed_at: now(), + placed_by: actor.name.clone(), + placed_by_id: actor.account_id, + released: None, + }; + let mut reason = reason_of(arguments.reason.as_deref()); + for (key, value) in value.into_expanded_object() { + let parsed = match &key { + Key::Property(P::Name) => parse_text(P::Name, &value, true).map(|v| { + new.name = v.unwrap_or_default(); + }), + Key::Property(P::Reference) => { + parse_text(P::Reference, &value, false).map(|v| new.reference = v) + } + Key::Property(P::Description) => { + parse_text(P::Description, &value, false).map(|v| new.description = v) + } + Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| new.scope = v), + Key::Property(P::From) => parse_date(P::From, &value).map(|v| new.from = v), + Key::Property(P::To) => parse_date(P::To, &value).map(|v| new.to = v), + Key::Property(P::Reason) => { + if let Value::Str(r) = &value { + reason = reason_of(Some(r)).or(reason); + } + Ok(()) + } + _ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())), + }; + if let Err(error) = parsed { + response.not_created.append(client_id, error); + continue 'create; + } + } + if new.name.is_empty() { + response + .not_created + .append(client_id, invalid(P::Name, "A hold needs a case name.")); + continue; + } + if reason.is_none() { + response.not_created.append(client_id, reason_required()); + continue; + } + if let Err(refusal) = new.check_new() { + response.not_created.append(client_id, refused(refusal)); + continue; + } + let id = hold::create(data, &new).await?; + let mut out = Map::with_capacity(1); + out.insert_unchecked( + Key::Property(P::Id), + Value::Element(LegalHoldValue::Id(Id::from(id))), + ); + response.created.insert(client_id, Value::Object(out)); + } + + 'update: for (id, value) in request.unwrap_update().into_valid() { + let Some(current) = (match u32::try_from(id.id()) { + Ok(hold_id) => hold::get(data, hold_id).await?, + Err(_) => None, + }) else { + response.not_updated.append(id, SetError::not_found()); + continue; + }; + let Some(reason) = reason_of(arguments.reason.as_deref()) else { + response.not_updated.append(id, reason_required()); + continue; + }; + let mut next = current.clone(); + let mut release = false; + for (key, value) in value.into_expanded_object() { + let parsed = match &key { + Key::Property(P::Name) => { + parse_text(P::Name, &value, true).map(|v| next.name = v.unwrap_or_default()) + } + Key::Property(P::Reference) => { + parse_text(P::Reference, &value, false).map(|v| next.reference = v) + } + Key::Property(P::Description) => { + parse_text(P::Description, &value, false).map(|v| next.description = v) + } + Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| next.scope = v), + Key::Property(P::From) => parse_date(P::From, &value).map(|v| next.from = v), + Key::Property(P::To) => parse_date(P::To, &value).map(|v| next.to = v), + Key::Property(P::Released) => match value { + Value::Bool(true) => { + release = true; + Ok(()) + } + Value::Bool(false) if current.is_active() => Ok(()), + _ => Err(invalid( + P::Released, + "A released hold can't be put back; place a new one instead.", + )), + }, + _ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())), + }; + if let Err(error) = parsed { + response.not_updated.append(id, error); + continue 'update; + } + } + if let Err(refusal) = current.check_update(&mut next) { + response.not_updated.append(id, refused(refusal)); + continue; + } + if release { + next.released = Some(Release { + at: now(), + by: actor.name.clone(), + by_id: actor.account_id, + reason, + }); + } + if next != current { + hold::update(data, &next).await?; + } + response.updated.append(id, None); + } + + // LH-6, LH-10, LH-11: the archive follows what's now held + if !response.created.is_empty() || !response.updated.is_empty() { + server.settle_archive().await?; + } + + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("A hold is never deleted. Release it, and it stays listed."), + ); + } + + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 95b9e26..249aee8 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -9,6 +9,7 @@ pub mod access; pub mod account_lock; +pub mod legal_hold; pub mod audit; pub mod audit_log; pub mod ai_limits; diff --git a/crates/jmap/src/inbuxa/undelete.rs b/crates/jmap/src/inbuxa/undelete.rs index b633d98..930c7f7 100644 --- a/crates/jmap/src/inbuxa/undelete.rs +++ b/crates/jmap/src/inbuxa/undelete.rs @@ -298,6 +298,33 @@ pub(crate) async fn set(mut set: RegistrySetResponse<'_>) -> trc::Result + { + let mut why = "A legal hold applies to this item, so it can't be deleted.".to_string(); + if set + .access_token + .has_permission(registry::schema::enums::Permission::SysLegalHoldGet) + { + let names = set + .server + .holds_on(account_id) + .await? + .into_iter() + .map(|hold| hold.name) + .collect::>(); + if !names.is_empty() { + why = format!("Held by {}, so it can't be deleted.", names.join(", ")); + } + } + set.response + .not_destroyed + .append(id, SetError::forbidden().with_description(why)); + } Some(item) => { undelete::records::remove(data, registry, id, &item).await?; set.response.destroyed.push(id); diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index c75ac84..b9ec2f5 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1739,6 +1739,11 @@ pub enum Permission { SysAccountLockCreate = 666, SysAccountLockUpdate = 667, SysAccountLockDestroy = 668, + // inbuxa: legal hold (audit-hold-lock spec, LH-13) + SysLegalHoldGet = 669, + SysLegalHoldCreate = 670, + SysLegalHoldUpdate = 671, + SysLegalHoldExport = 672, 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 922135c..312b179 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7080,6 +7080,10 @@ impl EnumImpl for Permission { b"sysAccountLockCreate" => Permission::SysAccountLockCreate, b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate, b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy, + b"sysLegalHoldGet" => Permission::SysLegalHoldGet, + b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate, + b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate, + b"sysLegalHoldExport" => Permission::SysLegalHoldExport, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7765,6 +7769,10 @@ impl EnumImpl for Permission { Permission::SysAccountLockCreate => "sysAccountLockCreate", Permission::SysAccountLockUpdate => "sysAccountLockUpdate", Permission::SysAccountLockDestroy => "sysAccountLockDestroy", + Permission::SysLegalHoldGet => "sysLegalHoldGet", + Permission::SysLegalHoldCreate => "sysLegalHoldCreate", + Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate", + Permission::SysLegalHoldExport => "sysLegalHoldExport", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8443,6 +8451,10 @@ impl EnumImpl for Permission { 666 => Some(Permission::SysAccountLockCreate), 667 => Some(Permission::SysAccountLockUpdate), 668 => Some(Permission::SysAccountLockDestroy), + 669 => Some(Permission::SysLegalHoldGet), + 670 => Some(Permission::SysLegalHoldCreate), + 671 => Some(Permission::SysLegalHoldUpdate), + 672 => Some(Permission::SysLegalHoldExport), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8887,7 +8899,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 669; + const COUNT: usize = 673; } impl serde::Serialize for Permission { diff --git a/crates/services/src/task_manager/destroy_account.rs b/crates/services/src/task_manager/destroy_account.rs index ee88695..1d3da5a 100644 --- a/crates/services/src/task_manager/destroy_account.rs +++ b/crates/services/src/task_manager/destroy_account.rs @@ -55,6 +55,23 @@ impl DestroyAccountTask for Server { async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Result { let account_id = task.account_id.document_id(); + // inbuxa: LH-8, LH-10: a kept account waits for its time, and a held one + // for its release; "destroy now" clears the kept record first + if let Some(kept) = + inbuxa_features::undelete::data::kept_account(&server.core.storage.data, account_id).await? + { + let now = store::write::now(); + let held = inbuxa_features::hold::is_held_until(kept.kept_until) + || server.is_kept_held(account_id, &kept).await?; + if held || kept.kept_until > now { + let retry = if held { now + 86_400 } else { kept.kept_until }; + return Ok(TaskResult::deferred( + Some(retry), + "The account is still kept: a legal hold applies, or its time hasn't come.", + )); + } + } + // Destroy public keys and masked emails for object in [ObjectType::PublicKey, ObjectType::MaskedEmail] { let mut batch = BatchBuilder::new(); diff --git a/crates/services/src/task_manager/index.rs b/crates/services/src/task_manager/index.rs index 07ce970..53161e9 100644 --- a/crates/services/src/task_manager/index.rs +++ b/crates/services/src/task_manager/index.rs @@ -229,11 +229,19 @@ impl SearchIndexTask for Server { IndexDocumentType::Email => None, } && let Err(err) = archive_noted(self, kind, account_id, document_id).await { + // inbuxa: LH-5: the note stays, so the retry archives + // it; nothing a hold keeps is lost to a failure trc::error!( err.account_id(account_id) .document_id(document_id) .details("Failed to archive a deleted item") ); + results.push(IndexTaskResult { + task_type: TaskType::Delete, + index: task.document_type, + result: TaskResult::temporary("Failed to archive a deleted item"), + }); + continue; } document_deletions[idx] @@ -696,8 +704,9 @@ pub fn trace_search_document( document } -// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at -// deletion, when archiving is on; otherwise its note is dropped +// inbuxa: UD-1, UD-4, LH-4: archives a deleted file, event or contact noted +// at deletion, when archiving is on or a hold covers it; otherwise its note is +// dropped. The note goes only once the item is archived. async fn archive_noted( server: &Server, kind: undelete::groupware::Kind, @@ -705,12 +714,22 @@ async fn archive_noted( document_id: u32, ) -> trc::Result<()> { let data = &server.core.storage.data; - let Some(note) = undelete::groupware::take(data, kind, account_id, document_id).await? else { + let Some(note) = undelete::groupware::peek(data, kind, account_id, document_id).await? else { return Ok(()); }; - let Some(retention) = undelete::settings::retention(server.registry()).await?.items else { - return Ok(()); + // LH-3: events by their start; contacts and files whole + let keeping = server.keeping(account_id).await?; + let held = match kind { + undelete::groupware::Kind::CalendarEvent => { + keeping.covers_event(undelete::groupware::event_start(¬e)) + } + _ => keeping.covers(None), }; + let now = store::write::now(); + let Some(until) = keeping.until(now, held) else { + return undelete::groupware::clear(data, kind, account_id, document_id).await; + }; + let retention = until.saturating_sub(now); let blob_hash = match (¬e.content, ¬e.blob_hash) { (Some(text), _) => { server @@ -723,11 +742,11 @@ async fn archive_noted( .into_err() .details("Invalid blob hash in undelete note") })?, - (None, None) => return Ok(()), + (None, None) => return undelete::groupware::clear(data, kind, account_id, document_id).await, }; undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention) - .await - .map(|_| ()) + .await?; + undelete::groupware::clear(data, kind, account_id, document_id).await } async fn delete_email_metadata( diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index a855484..bc1c4ef 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 ffa25a9..cf8d413 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc \ No newline at end of file +-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM \ No newline at end of file diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs new file mode 100644 index 0000000..cd1d523 --- /dev/null +++ b/tests/src/system/legal_hold.rs @@ -0,0 +1,639 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Legal holds, the object itself (audit-hold-lock spec, LH-1, LH-3, LH-13, +//! AU-12): placing, widening and releasing a hold, and who may. What a hold +//! keeps is tested with the undelete hooks. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{ + CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant, + UserRoles, + }, +}; +use serde_json::{Value, json}; +use types::id::Id; + +const INBOX_ID: u32 = 0; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:contacts", + "urn:inbuxa:jmap", +]; + +impl Account { + async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = self.id_string().into(); + } + 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 hold_set(&self, arguments: Value) -> Value { + let (name, response) = self.hold_call("inbuxa:LegalHold/set", arguments).await; + assert_eq!(name, "inbuxa:LegalHold/set", "{response}"); + response + } + + async fn archived_items(&self) -> Vec { + let (_, response) = self + .hold_call("x:ArchivedItem/get", json!({"ids": null})) + .await; + response["list"].as_array().cloned().unwrap_or_default() + } + + async fn hold_get(&self, id: &str) -> Value { + let (name, response) = self + .hold_call("inbuxa:LegalHold/get", json!({"ids": [id]})) + .await; + assert_eq!(name, "inbuxa:LegalHold/get", "{response}"); + response["list"][0].clone() + } +} + +pub async fn test(test: &mut TestServer) { + println!("Running legal hold tests..."); + let admin = test.account("admin@example.com"); + let custodian = admin + .create_user_account("custodian@example.com", "custodian-secret-2201", "Custodian", &[], vec![]) + .await; + let other = admin + .create_user_account("other@example.com", "other-secret-7310", "Other", &[], vec![]) + .await; + let custodian_id = custodian.id_string().to_string(); + let other_id = other.id_string().to_string(); + + // AU-12: no hold without a reason; LH-1: nor without a name or a scope + let response = admin + .hold_set(json!({"create": {"h": {"name": "Matter 4411", + "scope": {"accounts": [custodian_id]}}}})) + .await; + assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "AU-12: {response}"); + let response = admin + .hold_set(json!({"reason": "Counsel's letter", "create": {"h": { + "scope": {"accounts": [custodian_id]}}}})) + .await; + assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 name: {response}"); + let response = admin + .hold_set(json!({"reason": "Counsel's letter", "create": {"h": { + "name": "Matter 4411", "scope": {}}}})) + .await; + assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 scope: {response}"); + let response = admin + .hold_set(json!({"reason": "Counsel's letter", "create": {"h": { + "name": "Matter 4411", "scope": {"accounts": ["zzzzzz"]}}}})) + .await; + assert_eq!( + response["notCreated"]["h"]["type"], "invalidProperties", + "LH-1 unknown account: {response}" + ); + let response = admin + .hold_set(json!({"reason": "Counsel's letter", "create": {"h": { + "name": "Matter 4411", + "from": "2026-06-30T00:00:00Z", "to": "2026-01-01T00:00:00Z", + "scope": {"accounts": [custodian_id]}}}})) + .await; + assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-3 backwards: {response}"); + + // LH-1: placed, with a reference and a range + let response = admin + .hold_set(json!({"create": {"h": { + "name": "Matter 4411", "reference": "4411-A", "reason": "Counsel's letter", + "from": "2026-01-01T00:00:00Z", "to": "2026-06-30T23:59:59Z", + "scope": {"accounts": [custodian_id]}}}})) + .await; + let hold_id = response["created"]["h"]["id"] + .as_str() + .unwrap_or_else(|| panic!("LH-1: not placed: {response}")) + .to_string(); + let hold = admin.hold_get(&hold_id).await; + assert_eq!(hold["name"], "Matter 4411", "{hold}"); + assert_eq!(hold["reference"], "4411-A", "{hold}"); + assert_eq!(hold["scope"]["accounts"], json!([custodian_id]), "{hold}"); + assert_eq!(hold["from"], "2026-01-01T00:00:00Z", "{hold}"); + assert_eq!(hold["released"], false, "{hold}"); + assert!(hold["placedBy"].as_str().is_some_and(|by| by.contains("admin")), "{hold}"); + + // AU-12: every later change needs a reason too + let response = admin + .hold_set(json!({"update": {hold_id.as_str(): {"name": "Renamed"}}})) + .await; + assert_eq!( + response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties", + "AU-12: {response}" + ); + + // LH-3: narrowing is refused, widening is allowed + let response = admin + .hold_set(json!({"reason": "Narrow it", "update": {hold_id.as_str(): { + "from": "2026-03-01T00:00:00Z"}}})) + .await; + assert_eq!( + response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties", + "LH-3 narrowed: {response}" + ); + let response = admin + .hold_set(json!({"reason": "Counsel widened the matter", "update": {hold_id.as_str(): { + "from": "2025-01-01T00:00:00Z", "to": null}}})) + .await; + assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-3 widened: {response}"); + let hold = admin.hold_get(&hold_id).await; + assert_eq!(hold["from"], "2025-01-01T00:00:00Z", "{hold}"); + assert_eq!(hold["to"], Value::Null, "LH-3: an open end catches mail to come: {hold}"); + + // The scope grows, and never shrinks + let response = admin + .hold_set(json!({"reason": "Second custodian", "update": {hold_id.as_str(): { + "scope": {"accounts": [custodian_id, other_id]}}}})) + .await; + assert!(response["updated"].get(hold_id.as_str()).is_some(), "scope grown: {response}"); + let response = admin + .hold_set(json!({"reason": "Drop one", "update": {hold_id.as_str(): { + "scope": {"accounts": [other_id]}}}})) + .await; + assert_eq!( + response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties", + "scope shrunk: {response}" + ); + + // LH-13: a hold is never deleted + let response = admin + .hold_set(json!({"reason": "Delete it", "destroy": [hold_id]})) + .await; + assert_eq!( + response["notDestroyed"][hold_id.as_str()]["type"], "forbidden", + "LH-13: {response}" + ); + + // LH-13: only server-level administrators see holds, never a plain user + let (name, response) = custodian + .hold_call("inbuxa:LegalHold/get", json!({"ids": null})) + .await; + assert_eq!(name, "error", "LH-13: a user read holds: {response}"); + + // ... and never a tenant administrator, whatever its role says: a hold + // may concern the tenant's own administrator + let tenant = admin + .registry_create_object(Tenant { + name: "Hold tenant".to_string(), + ..Default::default() + }) + .await; + admin + .registry_create_object(Domain { + name: "tenant-hold.example.org".to_string(), + is_enabled: true, + member_tenant_id: Some(tenant), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await; + let t_admin = admin + .create_user_account( + "tadmin@tenant-hold.example.org", + "tenant-admin-secret-6604", + "Tenant admin", + &[], + vec![], + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + t_admin.id(), + json!({Property::Roles: UserRoles::Admin}), + ) + .await; + let (name, response) = t_admin + .hold_call("inbuxa:LegalHold/get", json!({"ids": null})) + .await; + assert_eq!(name, "error", "LH-13: a tenant administrator read holds: {response}"); + let (name, response) = t_admin + .hold_call( + "inbuxa:LegalHold/set", + json!({"reason": "Mine", "create": {"h": {"name": "Tenant matter", + "scope": {"accounts": [t_admin.id_string()]}}}}), + ) + .await; + assert_eq!(name, "error", "LH-13: a tenant administrator placed a hold: {response}"); + + // Test 7, LH-2: a hold on a domain reaches an account created there + // later, and keeps it by name when it moves to another domain + let held_domain = admin + .registry_create_object(Domain { + name: "held.example.net".to_string(), + is_enabled: true, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await; + let elsewhere = admin + .registry_create_object(Domain { + name: "elsewhere.example.net".to_string(), + is_enabled: true, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await; + let response = admin + .hold_set(json!({"reason": "Whole division", "create": {"d": { + "name": "Matter 5120", "scope": {"domains": [held_domain.to_string()]}}}})) + .await; + let domain_hold = response["created"]["d"]["id"] + .as_str() + .unwrap_or_else(|| panic!("LH-1 domain hold: {response}")) + .to_string(); + let mover = admin + .create_user_account("mover@held.example.net", "mover-secret-8812", "Mover", &[], vec![]) + .await; + assert_eq!( + admin.hold_get(&domain_hold).await["scope"]["accounts"], + json!([]), + "LH-2: covered through the domain, not named yet" + ); + admin + .registry_update_object( + ObjectType::Account, + mover.id(), + json!({Property::DomainId: elsewhere.to_string()}), + ) + .await; + assert_eq!( + admin.hold_get(&domain_hold).await["scope"]["accounts"], + json!([mover.id_string()]), + "test 7, LH-2: the moved account escaped the hold" + ); + + // Test 6, LH-4: what a hold keeps, with undelete switched off, so only + // the hold can be keeping anything + admin + .registry_update_setting( + DataRetention { + archive_deleted_items_for: None, + ..Default::default() + }, + &[Property::ArchiveDeletedItemsFor], + ) + .await; + let held = admin + .create_user_account("held@example.com", "held-secret-4419", "Held", &[], vec![]) + .await; + let ranged = admin + .create_user_account("ranged@example.com", "ranged-secret-5530", "Ranged", &[], vec![]) + .await; + let response = admin + .hold_set(json!({"reason": "Preserve everything", "create": { + "w": {"name": "Matter 6001", "scope": {"accounts": [held.id_string()]}}, + "r": {"name": "Matter 6002", "from": "2020-01-01T00:00:00Z", "to": "2020-12-31T23:59:59Z", + "scope": {"accounts": [ranged.id_string()]}}}})) + .await; + let whole_hold = response["created"]["w"]["id"] + .as_str() + .unwrap_or_else(|| panic!("LH-1: {response}")) + .to_string(); + assert!(response["created"]["r"]["id"].is_string(), "LH-1: {response}"); + + let held_client = held.jmap_client().await; + let ranged_client = ranged.jmap_client().await; + let whole = import(&held_client, "Held whole", None).await; + held_client.email_destroy(&whole).await.unwrap(); + // 2020-03-15: inside the range; now: outside it + let inside = import(&ranged_client, "Inside the range", Some(1_584_230_400)).await; + let outside = import(&ranged_client, "Outside the range", None).await; + ranged_client.email_destroy(&inside).await.unwrap(); + ranged_client.email_destroy(&outside).await.unwrap(); + + // LH-3: a contact is held whole, whatever the range + let (_, books) = ranged + .hold_call("AddressBook/get", json!({"ids": null})) + .await; + let book = books["list"][0]["id"] + .as_str() + .unwrap_or_else(|| panic!("no address book: {books}")) + .to_string(); + { + let (_, created) = ranged + .hold_call( + "ContactCard/set", + json!({"create": {"c": {"addressBookIds": {book: true}, + "name": {"full": "Kept Contact"}}}}), + ) + .await; + let card = created["created"]["c"]["id"].as_str().unwrap_or_default().to_string(); + let (_, destroyed) = ranged + .hold_call("ContactCard/set", json!({"destroy": [card]})) + .await; + assert!(destroyed["destroyed"][0].is_string(), "{destroyed}"); + } + test.wait_for_tasks().await; + + let is_held = |item: &Value| item["archivedUntil"].as_str().is_some_and(|u| u.starts_with("9999-")); + let kept = held.archived_items().await; + assert!( + kept.iter().any(|i| i["subject"] == "Held whole" && is_held(i)), + "test 6, LH-4: a held account's mail wasn't kept: {kept:?}" + ); + let kept = ranged.archived_items().await; + assert!( + kept.iter().any(|i| i["subject"] == "Inside the range" && is_held(i)), + "LH-3: mail inside the range wasn't kept: {kept:?}" + ); + assert!( + !kept.iter().any(|i| i["subject"] == "Outside the range"), + "LH-3: mail outside the range was kept, with undelete off: {kept:?}" + ); + assert!( + kept.iter().any(|i| i["name"] == "Kept Contact" && is_held(i)), + "LH-3: a contact wasn't kept whole: {kept:?}" + ); + + // LH-6: placing a hold freezes what's already archived; LH-7: frozen + // items can't be destroyed; LH-11: releasing one hold of two frees + // nothing; LH-10: releasing the last gives a real deadline back + admin + .registry_update_setting( + DataRetention { + archive_deleted_items_for: Some(registry::schema::prelude::Duration( + std::time::Duration::from_secs(30 * 86_400), + )), + ..Default::default() + }, + &[Property::ArchiveDeletedItemsFor], + ) + .await; + let frozen = admin + .create_user_account("frozen@example.com", "frozen-secret-9031", "Frozen", &[], vec![]) + .await; + let frozen_client = frozen.jmap_client().await; + let doomed = import(&frozen_client, "Deleted before the hold", None).await; + frozen_client.email_destroy(&doomed).await.unwrap(); + test.wait_for_tasks().await; + let archived = |items: Vec| { + items + .into_iter() + .find(|i| i["subject"] == "Deleted before the hold") + .unwrap_or_else(|| panic!("not archived")) + }; + let item = archived(frozen.archived_items().await); + assert!(!is_held(&item), "undelete's 30 days first: {item}"); + let item_id = item["id"].as_str().unwrap().to_string(); + + let response = admin + .hold_set(json!({"reason": "First matter", "create": { + "a": {"name": "Matter 7001", "scope": {"accounts": [frozen.id_string()]}}, + "b": {"name": "Matter 7002", "scope": {"accounts": [frozen.id_string()]}}}})) + .await; + let first = response["created"]["a"]["id"].as_str().unwrap().to_string(); + let second = response["created"]["b"]["id"].as_str().unwrap().to_string(); + assert!( + is_held(&archived(frozen.archived_items().await)), + "test 6, LH-6: the archived item wasn't frozen" + ); + // LH-9: what the hold keeps, for the console + let (_, response) = admin + .hold_call( + "inbuxa:LegalHold/get", + json!({"ids": [first], "properties": ["accountsCovered", "itemsHeld", "sizeHeld"]}), + ) + .await; + let summary = &response["list"][0]; + assert_eq!(summary["accountsCovered"], 1, "LH-9: {response}"); + assert_eq!(summary["itemsHeld"], 1, "LH-9: {response}"); + assert!(summary["sizeHeld"].as_u64().is_some_and(|s| s > 0), "LH-9: {response}"); + // LH-14: the holds on one account, for the console's Held badge + let (_, response) = admin + .hold_call( + "inbuxa:LegalHold/get", + json!({"coveringAccount": frozen.id_string(), "properties": ["name"]}), + ) + .await; + let mut names = response["list"] + .as_array() + .map(|l| l.iter().filter_map(|h| h["name"].as_str()).collect::>()) + .unwrap_or_default(); + names.sort_unstable(); + assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}"); + + let (_, response) = frozen + .hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]})) + .await; + assert_eq!( + response["notDestroyed"][item_id.as_str()]["type"], "forbidden", + "test 6, LH-7: the owner destroyed a held item: {response}" + ); + assert!( + !response.to_string().contains("Matter 70"), + "LH-7: the hold was named to someone who can't see holds: {response}" + ); + let (_, response) = admin + .hold_call( + "x:ArchivedItem/set", + json!({"accountId": frozen.id_string(), "destroy": [item_id]}), + ) + .await; + assert!( + response.to_string().contains("Matter 7001"), + "LH-7: the administrator isn't told which hold: {response}" + ); + + admin + .hold_set(json!({"reason": "First settled", "update": {first.as_str(): {"released": true}}})) + .await; + assert!( + is_held(&archived(frozen.archived_items().await)), + "test 9, LH-11: releasing one hold of two freed the item" + ); + admin + .hold_set(json!({"reason": "Second settled", "update": {second.as_str(): {"released": true}}})) + .await; + let item = archived(frozen.archived_items().await); + assert!(!is_held(&item), "LH-10: the last release left it held: {item}"); + let until = item["archivedUntil"].as_str().unwrap_or_default().to_string(); + let grace = chrono::Utc::now() + chrono::Duration::days(29); + assert!( + until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(), + "test 8, LH-10: under 30 days of grace after release: {until}" + ); + + // Test 8, LH-8: a held account destroyed as a login is kept, data and + // all, with no expiry, although undelete keeps no accounts here + let held_id = held.id_string().to_string(); + admin.destroy_account(held).await; + let kept = |list: Value| { + list["list"] + .as_array() + .and_then(|l| l.iter().find(|a| a["id"] == held_id.as_str()).cloned()) + }; + let (_, list) = admin + .hold_call("inbuxa:DeletedAccount/get", json!({"ids": null})) + .await; + let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-8: not kept: {list}")); + assert!( + entry["keptUntil"].as_str().is_some_and(|u| u.starts_with("9999-")), + "test 8, LH-8: kept with an expiry: {entry}" + ); + let (_, response) = admin + .hold_call("inbuxa:DeletedAccount/set", json!({"destroy": [held_id]})) + .await; + assert_eq!( + response["notDestroyed"][held_id.as_str()]["type"], "forbidden", + "test 8, LH-8: destroy-now wasn't refused: {response}" + ); + // Its hold names it now, so no domain or tenant move can drop it + assert!( + admin.hold_get(&whole_hold).await["scope"]["accounts"] + .as_array() + .is_some_and(|a| a.iter().any(|id| id == held_id.as_str())), + "LH-8: the hold doesn't name the deleted account" + ); + // Release: the data is destroyed 30 days later, not before + admin + .hold_set(json!({"reason": "Matter closed", "update": {whole_hold.as_str(): {"released": true}}})) + .await; + let (_, list) = admin + .hold_call("inbuxa:DeletedAccount/get", json!({"ids": null})) + .await; + let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-10: gone at release: {list}")); + let until = entry["keptUntil"].as_str().unwrap_or_default().to_string(); + let grace = chrono::Utc::now() + chrono::Duration::days(29); + assert!( + !until.starts_with("9999-") && until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(), + "test 8, LH-10: after release, not 30 days of grace: {until}" + ); + + // LH-10: release needs a reason, and a released hold stays, read-only + let response = admin + .hold_set(json!({"update": {hold_id.as_str(): {"released": true}}})) + .await; + assert_eq!( + response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties", + "AU-12 release: {response}" + ); + let response = admin + .hold_set(json!({"reason": "Matter settled", "update": {hold_id.as_str(): {"released": true}}})) + .await; + assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-10: {response}"); + let hold = admin.hold_get(&hold_id).await; + assert_eq!(hold["released"], true, "{hold}"); + assert_eq!(hold["releaseReason"], "Matter settled", "{hold}"); + assert!(hold["releasedAt"].is_string(), "{hold}"); + let response = admin + .hold_set(json!({"reason": "Rename", "update": {hold_id.as_str(): {"name": "After"}}})) + .await; + assert_eq!( + response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties", + "LH-1: a released hold changed: {response}" + ); + let response = admin + .hold_set(json!({"reason": "Undo", "update": {hold_id.as_str(): {"released": false}}})) + .await; + assert_eq!( + response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties", + "LH-10: a released hold came back: {response}" + ); + + // AU-12: placing, widening and releasing are recorded with their reasons + let (_, query) = admin + .hold_call( + "inbuxa:AuditEvent/query", + json!({"filter": {"targetKind": "inbuxa:LegalHold"}}), + ) + .await; + let ids = query["ids"].clone(); + let (_, records) = admin + .hold_call("inbuxa:AuditEvent/get", json!({"ids": ids})) + .await; + let reasons = records["list"] + .as_array() + .unwrap_or_else(|| panic!("AU-12: no records: {records}")) + .iter() + .filter_map(|r| r["reason"].as_str()) + .collect::>(); + for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] { + assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}"); + } + // A release is recorded under the hold's name, from before to after + let list = records["list"].as_array().cloned().unwrap_or_default(); + let release = list + .iter() + .find(|r| r["reason"] == "First settled") + .unwrap_or_else(|| panic!("the first release isn't recorded: {list:?}")); + assert_eq!(release["target"]["name"], "Matter 7001", "{release}"); + assert!( + release["changes"] + .as_array() + .is_some_and(|c| c.iter().any(|c| c["field"] == "released" && c["before"] == false && c["after"] == true)), + "the release doesn't read before/after: {release}" + ); + + // Accounts are named by their full address, not the bare local part + let (_, query) = admin + .hold_call("inbuxa:AuditEvent/query", json!({"filter": {"targetKind": "x:Account"}})) + .await; + let (_, accounts) = admin + .hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()})) + .await; + assert!( + accounts["list"] + .as_array() + .is_some_and(|l| l.iter().any(|r| r["target"]["name"] == "held@example.com")), + "an account isn't named by its address: {accounts}" + ); +} + +async fn import( + client: &jmap_client::client::Client, + subject: &str, + received_at: Option, +) -> String { + client + .email_import( + format!("From: a@example.org\r\nSubject: {subject}\r\n\r\nBody.\r\n").into_bytes(), + [Id::from(INBOX_ID).to_string()], + None::>, + received_at, + ) + .await + .unwrap() + .take_id() +} + +/// Runs these tests alone: `cargo test -p tests legal_hold_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn legal_hold_tests() { + let mut test = TestServerBuilder::new("legal_hold_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").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 4e27ebd..7717d50 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -12,6 +12,7 @@ pub mod ai; pub mod ai_calibration; pub mod ai_explain; pub mod account_lock; // inbuxa: account lock with delegation +pub mod legal_hold; // inbuxa: legal hold pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once