From 5d2e35b2dc9e78dedaebe6899980a1fd04ee4137 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 17:46:30 -0700 Subject: [PATCH 1/8] Legal holds, step 1: the hold itself inbuxa:LegalHold get/set places a hold on accounts, groups, domains, tenants or the whole server, with an optional date range. A hold's range and scope can only widen, a released hold is read-only, and none is ever deleted. Placing, changing and releasing each need a reason and are audited (LH-1, LH-3, LH-10, AU-12). Permissions 669-672 (see, place, widen or release, export held data) go to server administrators only; the tenant ceiling always strips them, as it does Impersonate (LH-13). Schema: Compliance > Legal Holds. What a hold keeps comes next, through the undelete hooks. Also moves the lock expiry helpers below the lock module's imports. --- crates/common/src/auth/permissions.rs | 15 + .../common/src/manager/granted_permissions.rs | 8 +- crates/features/src/hold/mod.rs | 429 ++++++++++++++++++ crates/features/src/lib.rs | 1 + crates/features/src/lock/mod.rs | 10 +- .../src/object/inbuxa_legal_hold.rs | 224 +++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/method.rs | 10 +- crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 15 + crates/jmap/src/api/auth.rs | 11 + crates/jmap/src/api/request.rs | 36 ++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/legal_hold.rs | 417 +++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/registry/src/schema/enums.rs | 5 + crates/registry/src/schema/enums_impl.rs | 14 +- resources/schema/schema.json.gz | Bin 151198 -> 151262 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/legal_hold.rs | 287 ++++++++++++ tests/src/system/mod.rs | 1 + 24 files changed, 1502 insertions(+), 10 deletions(-) create mode 100644 crates/features/src/hold/mod.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_legal_hold.rs create mode 100644 crates/jmap/src/inbuxa/legal_hold.rs create mode 100644 tests/src/system/legal_hold.rs 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/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/features/src/hold/mod.rs b/crates/features/src/hold/mod.rs new file mode 100644 index 0000000..1792e82 --- /dev/null +++ b/crates/features/src/hold/mod.rs @@ -0,0 +1,429 @@ +/* + * 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 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; + +const FEATURE: u8 = b'H'; +const KIND_HOLD: u8 = b'h'; + +/// 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(); + } + } +} + +/// 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)) +} + +/// 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!())), + } + } +} + +/// 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 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>, 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", + } + .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, + ) + } +} + +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 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 = (); + + 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/legal_hold.rs b/crates/jmap/src/inbuxa/legal_hold.rs new file mode 100644 index 0000000..bd9e065 --- /dev/null +++ b/crates/jmap/src/inbuxa/legal_hold.rs @@ -0,0 +1,417 @@ +/* + * 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}; +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]) -> 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())), + }; + 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(); + match ids { + None => { + for current in hold::all(data).await? { + response.list.push(to_value(¤t, &properties)); + } + } + 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)), + 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); + } + + 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/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/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index a855484b8428e51c75d8fa8c4b203629937c4059..bc1c4ef006e69d0fb1b3abbc011c5d47e6b6b742 100644 GIT binary patch delta 19805 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 delta 19741 zcmZTvQ(#?P(+*B-pV+pY#wcy(M4&OESMFT*s{*C!t;10 z4VW9S8T^z@%BQvTu*Kxf9l6SnZdI*s-|ecy8AB=DxC_>?uo0>c&2B3b$w>F__>jA& zhnwNJ9o!||=q_gX72u2$6{m+P3<-m!C#J|NZ-0?2FwKaa3K4;VPaa)~RNURLm4vVm zMZ`?Rs`Zw6<_Y%U#O-LBP)Q_vjIyeU#80?s92mqLlN3g6 zpG+&PCatP4Gt*!}1KW#CwCMA~}ghs197N;v$4^!6%NE(%b8(b^eyG$KHVSDo1hLORTQX_Cas6u-(k#(ko4Yw8M`*AYnMw=$@7q#sSMCU>s_@k&g zf*egT)yQ`$7N{1Xa(e@6&@v8lg6^w2h{JUzX%)Y1yg7l|LvtXI&f1A1YxgjvnD-c> zW(QVuFcco*eyqWJjxDsXK(pXnG4V{-iCHBf_d_oc7=I@=PE=mfYKU4IL@WDky_==j zlr{p-Mj!;_iR01gQmGN1P{KJ0mChOX9jZyYGw#>Uca0{;N3h2s*3_B3hX)~O>B{_2 zHO3vtjYHJfKh}ZSxCC`iQz3=04A3>c$da>Eq!hYW1W9?aqSErIF3KMQ=!0Y})1(zL z!{JT)@nOQpv$paz+3MdYYCz1D4k0|o>jZ|zTU1VeEiljEkXo1(sYB6N-0vq?b$+6PkN?>$ z!DWS|_g`Vfwug$DKs@C&E`Dx4xEC`NJ*JCECz+0ZTB;0&X7oVNHs~v=Kd=!mu@BBFT4|4Ei!)Qz8(VS!;O7V3r8QS2{Fq2&L1Tf2g!K ze@aXUsSin-ik!JLD}K-TWT^&4Q%eeyAvdSH-P=D3(w&-?xjLszv_K+Ul8hrNgRC4R ztX$k8UrC(Am5!s)rbWPF&%#{q(7}^Bi5 zlxs2M&C%eGCUOG<>P5q)`(jjr6Kx{#rMf*~>l-g+4IHO}W*0AIw?9$AZe8gh81<2- zYth2c;XB$)VEdI8J8Qw8y6>jK`C?1M{r+{0GQv?FWchWGcI{R+Wcc@)S!2+CtyGOB z`6ZFa8AXYL-smN(XifJbMl|>=T@_Kk7~0eIQezzt=zS&axYWg|_Q6Wla+>HnvgnK4 z*DsK=P+~4+1w1PkrGys@B4JcPSV9m7DW(;`oIW7Wv;B7ygya#-??qQJeef&QOUt-EhLh$Me)2~!Av9FT~5!TlS5@K+~> zR`k&uz+E=e@kG;!7W?okjuV2BefCi&3`J$4I|@TU_YT)%13=@2n&>}!QV1f88oL5L z`|pim16j=mf|uhXPzi8RiC8!px|N?_xtMra9L}kf0f+S$T!YJ?;#X1`muD4?U~!1# z(i_OC(MUk)^nuhe5*FMAS0=G%#MDu^egF5swHVnTceB!IOXZIQ`KA};H_K=U%dBab zO>R9*mK8gpgP&`K1=dY30d7rD#v|$s=^UmBA93uXmWVNgs0-Rx0?VuW%EeyWUQ-Rf zetr+`zFRYk5AIL^UB_{c5y89$1TN&2EJH1@YxeZ}jK1P{&aT5k-1z81K(g^9ry?#0 zm(k*N|CB0l!GJ8ZDgi^zjRFz~!a+G^WQK#E9_4_cXMP6~aE<_!gKRJROhGm8fr0lI&=@k!E#hMzB#71&&@wG^JV0w8w0z4156~VFTA^*2Cuai;F{9d# z_%|a6k=ZnOyQLbGcCIeN0#`xhnpT|%`aS&@pDzGjKS3(q=fX3T)V z7N&+iGW$-92tP$QYeqUb7Yn5BSi*=n=zn(Gq=9*Ob$S;U^hE+eoGI77m+c@}!y4Rx zFh#GGhCi<7Z21G5a=yxzMS)ON-ekWY7t~64w*x;K@~)A_1S1ZMlABcDL3D~ z-s^(#?Lkjj;mK#Qe;~g zu;eRqd1}E%{LP+w&excO0@MJb1Vc#_Ud$RPn7~JO!$X0QvKqV=PDunqsq)oD$JBtM zEWcAFha0urqp5i%z2IXZ11rM%G>NDS8W2Dp)8}9h_;@(vA@SIsB0<)g*-yAX%>@7L zG0ULeO$#di`SFwMy`lckF3;x&(cPabx=#Y3+dpRn%zupFS{i&gKyxe5s{Y+FX3CQG zh#b;mMEwC#vl>9uyqQ3liy_=(0W%;gFP<2kNhrP=1!oX_`3-J>DH|tpo6K3_4p*!> zu1Lh#G;Ew_iI%_zZ*nb@4*qsL$E}4nX%LE>ZRSufYT`{9NUGQAOjj%dCkj^&_SKmc za&%QhxqBO)3_hs=6i|@R^C5;wVf7}=3Nsl_qipjaOxiLXjB_o>gHQB(;QFTE*wSOp zjDKa|k7sEdr?8+}8y;AGg&I=8>~ab8*wv--HD9oI3Mui4>~q{uqt!!e9TafhXU}9n z&_l&BZpI}(nf)r|OnBa5-@_>m!hGV~&GD=0ROs*Fg--&x;@7#{(QG~&8gSgecSvzB z1GpO@M~aHbO@AC7wx=VH>JN`JQ^$&^a;p((bY)3Gs(GjpX?NvFLb5xQ2{>PQ;~^<* zDtQ?n2H^$u(6kmARMWKnOoNFS+vb39ZTi%VRZ8VQ!23cM^E9iBtU5gw(;Bv;F{+b` zY8A6woFO1lrBWfW->C2|d=I+0VUA;POJ=60LlJp@O#kLVQnvudx_i&ytuIc;d|yFT+i`lW0}15(OYeJt@`dx5>Q#F?X`1--D%YVF?~&OO4mbGeeVL zE;F{tILV6at-&Q-lVP>%!+bIVozKo>TF&d`m#3BlcF0|YL+iPt&c}Mg!LgGxUF*7q ze<_1VGmnNC1=ynfm3?0F8tH$GRAV#M0*+SW)N&O7TChizHgD8}zjkDkS0MR)MO~HaEDcMyYsSm(=`4zHco86+oRQANxBO#x5$83Bl<9}YwU z_*O)L8(iEdy?*TRA|BJzkbU-l4=Js#d14AHHM*^|A#Hs9Vj(d1SFBAEN1Geutfqb_ClZ zE8NFDM+YWkM4=Dy$bfXWsp1HCz|J{!C)t1T^n=wZx%Ip0Y!-mq6kZUFuD+PYB-5rNX-a^U&{NyCGl4}FG&2-5Ei6D9hW!7v^w?cuqo!1oMB#f4wWGri^r zJotiP;G-_=$YMhn!h2z{%GVNTo~R4>Is{9EL(0Ya48N6!!7wk98PYCOOjz3V8kHN3 zkcB^AZxV9Lb2Z&y zxjUO0TS6THY!KhDDcd~mDHUX!vuiwf~bKKLNAkR`6mZ!?b38WhlI1qsp zhH2p{W*afqJyhtVIrmTypO8H)v|e%J`ozFKKlG0bydt@tE!EBJ=upB3HMP#}P-Ch# zm>m-zaQDKUVsI#ED->a}D+nmAeB&&v>O+2N;_a z29ld*rr}c~=jdMJN_CO};(W20vq^IZNsvLcRES+(oeTuj+x;yEEuo8rEEY zHBtpkDLC({$eOd(WDLRJ%eE+hcD2 z#VKPfSDb&I@C2$;@seGOWOUpT@^$23ovgnT45#OBbk@-8Axu_Hl*y0LkryC z=!BtJr!ZA-9q{}FRiS9h5#Ui2zZJ@A{SG4TDZFW@JwPCNY}G9ow+~sLpLx_j7_Olw z+#VNSA^nLy_8S>^~M_nP3P^o z68`omm{34dV>%uE5zo?WQd4zvIV3(L!OGZRB02A#)gl!P3Y(88Y&0ef*6FTE(=gG* z=)P~@XH&{3yUQkL#~8bwF!7{hogdwfy*S5zpYsh!?~inLl>r^5$ezH zLx20D=g3?m`^0~ht)?w$gtuqFK2BnNyYL=i&TqOJXV)5!EAp14!ttNQ(7b;SEW$1e zg=?3mmIGzlm?>Tzp-P#UHI_alLoX7H=IZUa{z~!eEb`2 z^|+ss61Yp8K7TabIdDFGG@TwT=rLWb=E}yBcLLo8G0#{?@*Q#Q^BoDe@%tpR<$e@* zt}`3yWZ$`M58BT^ptto>qBB08wB9t8NjI+^oo&f6`vi+ee^e`eoIgo(tbS(h)wB&T zVI-Z6SjE5;Zr8F<3YEGpzhw+sQz)&DE8P*lyu|DKm$Jg3wJNAgTzCY-UicHMx%;b= z^@6N#%_idTDG%|5!hWkLpN^&8rbG}bXMK*%UhVk&HKaVq4`LiILX){waCau85Ok@g z^x`1%!fY6v95HAwvQCa@5}>apcgb#p@;agFz^GUrSK`B6{Utc=YdTk5gx>I&@Q#DU zc~RaQFe$X#ja>n)2d`%f?BWJIir4&Rp$T z`JMz>*2K!xiUVKBZ^>p}0M}8S&P|?2;G$3>MqrawSgW|&o2V*P(oNXWazza(^GUhg z?T;8UU$$T7!AOcjYD_~%jwu|lr69m}$ZN|1;O*e@nSKdMO*|gd!t+eO7!jNB(!jdK znQiT75RXr6{$)Swl;_C$`nSP`9w^3SVfJYA61{9OPo83@H3>Qkp`r2^Q&S$Dj8NR9 zKiX0!hDx?JoEm$?IGS9KTr2L(z}F}h5BB26kzmWh!Li2B$Wa{e>b$iHB+yy%&NCO) zUptfmO9uLW4?dZ{mT*Tt(oriroT3!PgUJQqbR#Lhld(+2d$qzILR}G_`XonH7M}#C z_h}eD&#;}v7hjE-_e}|pFioF#(gSba4(~_b#T}BZkiE2YCK|KP@Xy58?DW*U{k=RO zbw@%wR~!GHGzZA3yTB<%CxcF4vP%p*1Rf?#g5*T9Vd|Xhv8`9Aaw=<{f><%vm$!&DYZrSp(*Rs>=owS31lu3fm@prlx+q$ik%B|t%g@1uk$>`C7 z(X^4t+-dGu{E*r;nwP4u*gP6R1B5tBT5o!jHmNmKauMTT8?0~oBp~bYhEmEq2kP_E zni7vLNVe+aWW@eSffh}P#3X_zd7}CEKCNqTdR5@RI5hcei#(;Yg3mVo8&`jU>_9I* zhQsvhbpsAnI4!Gd8#s~9q!S_%hq|Lj^wkpKVAg>|$D*L^frD0uwl?>vF2U_fzYzX! zJXwP?cBzzQPfzt{cA)cVlf;SfwRM71@kB*s5=|6Lrzrvd-YJW>YgO7YTi-bO8^oK6ps!@*I+`8<>?7RjohM z&+|sr()GaA=AJ8vQj?LT|AsO(LD9d3HdH`g8HU4))jYH91{&-PCa-T<$ydwFipb_} zz`SR1w~sul7F-L+#z;^r)wJ((pTOv za*s1@BO~o7?xn%q#j5?yGfw-nZ|{_o_FD1-rVQ#}5!6b9$BFIpa~TkWaLZ%dRzQ8? zl^T7h^@W{+VxXApfg-EJl zH?D@P7mzODls|x*5q`v@U%`08m5`QE?28&pFuro`9#2DwNhm>_Gew9{dj9*gxX=GK zR}Y0}&WBX6hdhQkE+|@jN z@C)2-cnqWCr6)*V^Gz1wi^9klJfPU>dQR2=eA{ub1@wBS;Kh*r{x_P_oi5Q<$f|8D z4gQl9(#Brl!BJ~1unu@&tyT3)r~O})$Le=K^CfK0#dfGUU;bw{Zgd^e3@G7gn<7v@ z$AnI;A*f%EZENLzBt5pr?O#Nn2&L{tQXjJ2H_zgx-gbAcH(8#M+&%&guAEivT2LPL z8T?c?m!7R&hyFmx?B#K~5j83d!aplh;{2(yWc=dk*)nN%D-zo0)3y~F*w3OMX-JV` zo#?}P=DzOtu5o`dHF3!rVfOU;U2?0=rdFmIdCWr+$IXN5C3b>m1e#e)-2_(x z8$?A}x|U;3P^}=Jn-||SJOcQ&ZxnbGo}rO`0|rHoB(Str6HW`+q=pw`k-*(miMIGt zqte16Ge$lTWB}j99wB}GLz&B5c#%WC(l|~kSPmD)^yK)~S3-C^ILZXByb!1ow0Xn9 zINgKxkFADPpPm1WO|yLmmzPAys}|*k7UYDJrz7QeRsg!ZtT=IhMG`!|w+NeK5pTn* z=iua|LQa+kN^+0lzkDiiie8nu8`-txcNx46uSnU8S+xGCx{CflvimVLyT4&)#JFf9 z6$=KFXleF_%aLaJ=AtE(0Z;R89aqEG)5f(z%{I_YmH2PblCm80sB);|xPGi)98{l{ z*dZ-FQ)zn0^X=gK*m5cMX0;MKci9ffwzie`yS;x(!y5kiv;1gdW$&6DokZ)DAoN=? zhM$aj?OirIhZ3NKFN5BFCe>>Br+vUnVjwV2t)1>RiZ4OC5g|Ungxz`1L_u2<%55p~>5t$a6HZ0!-3ro)&`=#HJCWyn#7{9E7D)V^UTfGCZ=H2G0Jjt;2 z5yYnIN$y)BnxXsD?6}p$lD$9!`9oilt+bjuEoc#1BvwWHRft*oXkO0oD^0yxGa?Xd_4q@~nVo zZ|5$EU|>GIFuVw=UOXcLJ6_VAg+JYZOk>f4$C@dB3-!u7LzWf{!0&rEw<`95ImJEN zbB2mC3?(MrMsuK3YSDxXey;7<>`#NE_IEe3N7T;Pa^Tl8;;8ws(0>WBcCL`Lv5jB` zh?N7-e^MD>psa#~ibv4zt?juW6}ElkP`vq3F@7#Wo|TtARpDJ*d>$HtSa!CEjDxtH zD^jjxf+VU=dY97mmQER8^^QH7rNf^)s~qD2R$<(<$4-}5_6C_$R0t9+Os##E^zDe! zPieg`^sOP%OT5Nn&+WX{wr}#(g-4MP9Sw=0@C0z$SV$mz5UQWNUAtc99t`G92GLIg zbCi;dlR;!&PJ;+kwenJ$6)Xn@J*;aK!DD5@*rJ@5y#2AHHGkUUvmqIgwzWccB|tp7 zDQcuFy{zU$F4RZ9U6J4}KEt4&?$IGL}k&h)YGt0cS3Pe=5<_FhEJ`dRt_-qRiiYKr2*skUy`)wLt#mP z{@-9B1@PKu1A4l%C!`*|q1f%MA;Drie4Y2|S@dq4Zz+CW z2}*!k?xA6gy)rO)J8Gn++T2>D&{1q=OdZ8jXE!O2)ktRqZ0JZ>uaEVwx8xeme+*H} zOhF2{mV7%X`L=06Ge?Pk;LOo&!P0#AU3oGiAT9AH!;lGs6r{FykXBqs&4HLE5q5PF zg1VP<#Dt^g#C(6sc0q~~tQ0PygqJ7kgfbXtB3MibdQg!F&(WD-^B zU;FktA*t6fNhOfNNfW~!Q+bm$8~ZQ8jGczCCJ#&~3-soNx$?w{liDB#i2c^(V!5?bSChnlu9M(eiDPCuA~xuG5d?dLCD}znqw$ zpRL094V6So4s=6R<`%F#>o`DGzV68gWN4|`S}Q+Ei3vh&uy_9L=?sswCu7m9e;ft} zRkNDv_~FJK3vv_AI%P2d(w3`Q34Kb75}i9C2^rF41lj?rZQLQl)OD-Cipx-(x1Y0f|jdAV9##1|TC|c#> zk3S1iu>J$%xZ;w{Z-`sn`=?$cDh>OX3``Xf@_$!Vs9~zKCdThMBvCJcyts)|^}z<4 z!ET7|u<8dCe$uvdB+0ywuWHM_SwjvBY!SF)aNjvFGtoYCnERQsPh14Qrq}_gxRzq8 z^I`EY3MnjhoBfSjBum7kbJeucLEyuu`>j?!r>CyH<}t|>3ZFaVdF0(LTL#gmmEFwH z1kJaf6{Vvt*}h-OohY06Cr~!7##X^te5bIphOdswaFBFg)2{?{#a2DX3mxqegKei9Z5#6jn|RiZ4V(N>%B z${wkH4?VNKbWrJS?j0gh6m-}8>iR2=w1C3nohPVfztHDlQ0 z`+uMBQoc?WP1AIN&EeyEbp01>7Ir8chz-kjWMjzoEpayjKGjX#6Z zS=6wAKd<-LaVeQi{Vp*wv%ruLL0kNp%mW}e?YEEZwK7dm94(rBkur|Kc8n-oDamgG znAg)pdFwoqIUjAl#l*N;FYSVv#VUR!C5KLD>$4`fNDSM%@Y6u%da4Ff6uZlH7%rC| z3rKocY+KaVCrHq5m5Za)nfHhc;z)5$Sj^SZIlygUM>eutoO-cb9)KcXwmz9R$71cV zaV*C()&CU)mL=3GS;djaq3LMgRSXEk8pm&SB+JEstrIh=nsw?l`2Av&E3)sh=%l2S zFavpRJXqCWb$!d9@s}bKi#gq393CEm9zsjQj(Q1GbF8lwTI=_|Ykn%F5r@of=(CIh z8c!n9yMb0tOyRXCOOgHR#jNl?;Lahn)={v$=|>KzzJNMv2E1RW_SrQquEby(oJ4Tm zm8icd5!Q}4Zr~J{?%Ru-m;p;h-}$HbC7*C3NiY_p`lc|}US481q*LG!ff}wF*4?lb zk_MjKH7RY*g;;p1US&wl!5ER zQ>%mQ2e6|rs0Aa@_zmm_IMQm8@`fV=>1{JF+IyFhc^V*EQtPj3+ws6djg<~Vlwej}X&?+UPiC%@-E$T#}N8H#aBx|A zS5l(-qf%(=q?a+{l~+8?`IMGV2^2r8V~Vdu3kGfpj1bA_;mh>euMS-*tgAsJ0A8k5 zGdrcd;qIv40k4?S?ew#3hVt{;B6Et@#28<(()cgn#cITxjQ*9=NV9iIUbW|Jm9iDJ zF7Q+)M_YsG1tYTbTC|>PWi1a!jU%4~PfbHeM&>>uh|qo)4U-}sxLF^xRV<~9hC5&N zn{&4TE=T(DaPb?xdopsdcmjxr)pN;enk%T(QFNx~XXaF6u*xWv1tmo&jtzHyKLzxC zWC1u-_B|3xK;Q;LEh~#}VSr$To^g~dB4QOGa#MBb%HWV>LJWcVN&`~(IO5xb)oN%> z2mBJIiPnzpb7Zk+50luyfJ*u&S4(cq^XEMq6X)?i`|cM$`~>hp>m$+yNlHfjcKWp z&Sc{`JdzPRr@Cm`iJTyiU@dI5v>KMdBuFZehqavXeAy9u(i+NOfeg#n`y1#Px5Stb{OBD4$7doG4;TA)VcOmoc9!|nE&o2qi`VJckpyU-R@H^xFD^> zkqh1)+@37Qk+tbG!da1XU1~NP%a)7N-(?+ts;S~PvC;MJ@{1n%>v9Yl=u9FpDC~x= zM+tg;|GG8c)S&)2BVK4c>Wmdz_ef2EBp}GH6<6E3p=}O25o3Ci?T^JKRXH`Q>8Ct# zzj@A?Y1SazPxQZ&V&iqTR7;qnFSvu6a*OV7jt5Detk(f8E$&R?6PC}(Ikfjzf|X;VVHD4qjxfOkod^NLm9{e z>q1vr8W0#IP)qYfp4(x`d47h+R_(@S5}p;hdTt(dvS<+aXI?CUD_ODh+{Y2V64yUf z5ztRs?$naWHGt)n&IXkbnc{{p`_kd^Fd_INsE?nU}Et`CIwlTLyn{QpG!Vn18F(T zSojGT>rK*5R)A}y3+hAQ&b(}fygo7=;f=Tx(4>+F=6#n=FA|1f-=67p@ps@9FiUW zg-srgNG({kr&p|$-)^sakx|{U|D=emR)u31zyPJrGa7$In(vr|@$aOV@IYa4c*kph zib|sNgD5yk8hiX)`87F9P1TAJ-r#BUa(TIxg=F*!8USIzkSKNb`FGk7zKWB!2`5!& znw&X&mVt|N+CP*aC(|9Fl=XQ@0(j=;$sLD|R>4ep8?vv*m`>Ib7JWHQcY@X^^(}u) z=;UveSKMK7bl!QndiYt}Rwm9!jIQZNlzg>crzz;XDisla*&2>p{r*}urks!n^ZtC{ z8(m2_zo^L(xLF2-6?SEPXAxwRXrZY+SM7iLUp~*IG%R?5z!5!jHnzghb>_LfgmW#Dr)5iP~H|gUZvkjL>(vAqWYwyL4*qv?#`nU zib$+rMyh7}8IS3_u_*sBiDQ1lX6nX5;tLNrEbcwU5(p6>|=k)wNUdInEjp4 zo2S$#e3MkzB}EHO%PKS|*i+f~54qK*>l@1cQpELv#}&se3H7 zMXc=Mu|&J1-*~Q2%VE>7A;?wH6bi`Ff!vF|kj}K~u<{HeO{z1n`{nA-R3pE_GCJn~ zbZp^szn-^g0Gt+VHYyd@-@3x15|hTM_r=iELJDy+p}_8l$W(tA|i)%2*k2 z{+LmNREH~;z?CkLJ^9)qT+*K>Pa=m%ROL$_{H+8-8T_*mJ2)Lil#(}@K;Ayqe&Qv{ zrJs-N7Qq~0H)2432> zN?1%~q2Ya3M@?yTo8gF%Jp!By2WqnYLXP6|#O*y0=ZHiLt{+8-iTFyskoLD7Zkm<3 zKwK}s;V23htAX2q?Vy&Axv8<}pjV{eZeBZEN~Vt^`n2e3wGavfCIDFx1tzL(>2l#g zn}2QpVvwbpwC1Wzt4}aoX5iYT&cN-I05-8Sa!|KrVDYH6%krC~H04!1f2L(iePtWQ z+nyW|AWb-d6@jC$Lh4*`QV75LKX=g1J@LA`PC))3?n zPyCc>=&3UZ9Con}m~{8OU|k8PiPv~!(NLk{s!ROTgmD6X-QVds z0Te7`j@gqnC|_w2nu%^(9yl3e@k&#J8~Q8ZLQ~=aV@3;0nJ;hs6AWFM!&VnAae**i zQSsY2XICsQV-31i@A7+WT&U5)SDCjLB&k$IEN%ON$oF0^jUhE%q9#uzH+cy9-mZUv zJ#W{+hg|r_ai;jvD}_J0@o3IPj?&Yi3NU~AIM6zJkvvtlO~MmfGx(eK(Z|uaAmFCK zBp5P%w}bP;S)I-IZ@vs6o6dWMPf_E z{W=o1Epv6zV&^F+Z4YxK>H-i=4k}{Wa-J+~B*uo=wJ+C?Bw+~$_6Qs!R;&WJ(g=Gv zP8azCONCrr!zH=_gR%*i}`Zx^GLBDtn);g(iR#upEJIm$`yH;?O~U-Wsap%OJ6CRrDf zr+Dm?u&&;5Pi*3>rOiPF>;jC~w8an<=L&8I^$epfMC)+^!0^+^GQWZwZMz?tN#biY zUCjrfn1LAMPU8@LDz(#yWYoI5WP_115Y&SVC@G_`hNYnVuB@}JFUI;CwYy3NUsHCG zXTbM?@-Qr&jD<|A{?^5w;ftZv3KoJo zNWyMFDL(nsI>&gLsgo?zJ$~22rA_dBUV%RJF)d!-JBjW7uub6 zxv(gCYNe!ZIv4j}W5gB19{*XY`6y0^8{|L$Xl0o*M3%*fqm9WhS8nCWC4w{HQG`k^ z|1aC%P;$po*|d6i$4s9_2|^XRy{65W1;u)G`xm!)?&0c__$k`Dl%Dv#DQ^gM%q3f` zWG&`Y4ui$Hw#HD-x^;)LoI=>|{Ge3HEu-QlGt-7$t&5d>-qMXaOK9A@sdg{d*7Fh` zl-J|`$&=Z~?K!NJY)6RnZCy(2Xx-3oZNF(i!X@X5gln7tf?h8krcL7Rc8Ov-Yy9h5 zuHy3K1>k+k=))4vsekC2*{EB#w9Lg=W}C=}>h6xV5lBpAY)JG`Y7&qe3XY8w%m9F9 zO5g1(%BL=v+UoOtxMMbC;dvVXNqwURaj)QfiYpSiBr$4bBYtw79LQ}u3_=MB3h{nZ z*tzoM)3qU5*MmcLt*4@K6(7kU=Bai+g*^Ss)oc0H9CGz)o(7*CaA}h}C-irwGCz5| z5T{RFY~@F^5PDkkR|bF&|J&--)dPF?*)G}0MY%GGeE=&FKGOclPRv_}V-Jkb$# z!1$#8%X|Zu2_axZt-bMpI5&Vp$<#tUyu!Tx(%xuLAL-Vw!5~%S?HLBrtXM&fj|)MApA^( zhF98O2`k%=5@&kW-W0_5@gDU0HxE8~D3p!edWnG#h{j}kCNWQqAllg^w7#rarT8u8 zjS;Y4W9MAK>1^jVm_}iBOHnBdotnP^<-hyI{7ufX3+}26b48MKms<`&cwSUOS;FRH zJ``X7X3X!;HLi;0MS;DPY#I5UC#h*hlc%|5l zPvS_Kqun?5CJ_e$@z!8GsYzX6uDfe4P*K>xqiu-m{-i7PGBqttIjb&LDA(DyhTWof zWKw8nd7+$T!v|&R;B%*fM0IAPCg5vG{Mj>m`Go>a0L;slkDBy4yT^kt_QF=lDg(*2 z*cW#04yhEGKl@q683y!6dk0n(wCJnqxncAqkQ<9n~jbmcDJVscc;yg=zodfOzbxWgu*R z74!9N`X7IP3#IZkP$9eCuJ{L$xMp2bs)fs@sae*(;m4)KzcfNZMs`)gl^qk4->}^) zj8vBU8*C#J#Cku*DLR;<{m3E|AgJA5CevyJgcorRg3T(H$_ur9hSJHC^q^;L|ZWet}B7gG9tE>ql6V1yh`#zNaQcEH80k>^@kas)@T0aU*%oP_Pg7E z*Ds+U({MFQtOGS|P&daJSVFvY6_5Z{B2)%22v4;*#MP? zp!)zAA7{u8s?L~T)w4>>TN7FZPI*f2K>*^&-!oc1>-2Oec2m{PjbI=d5>(R!Rcf=%UFSy$` zfLm8ZAdmCs89@GA|1jg+ZiI| z1;TSeHo*2ouQZD2tY`Njiz}CO$GQHu4Npb5Yl}J7B(UMHRJ&9*Ldr(g=i(LB6T)88 zuA=#(AvJAmNI4r_gXzY$;i|{p6zxYpWp~~^F_8oY$AObm^GH^8Y8N|yY?2HYZmfZ* zJU=9kOyW+u(-MN%*cN0e9Ma@wsV)GJ0ine|J{*Fz!CI_ZVnSU)bRo);6;to6*c6NB zov!c(5HL3pw)XqQ4#}S(UI+EcXy)?78>@Jrj+N;4^fO!v-yl#GUbFI;TSdey@9u?f znb*sdQc9eU1(g!Mel+iK^k(sl{8M4A$y!jTWdH5xtMws2trR?C-Y|_%pj(f{1IjcS z?}>SrPaeDSHL}>+p|>YdIJ4Cm(?W$1)KAMIjDdL!D~TCJ`6@&*g`?CA%MW&7?jtQV zcBQeo=$IYj0>qB2VYEt?M+BoKStV&H6}I3(DV?orTjNzEmNk8L`v;f6)NI^>j}^l) zKfHqxM^}W`5;|>Krz<5NLV98`$(rf7tAK7w9-OO`c#hfl(39CWmI9Wos!-4%V*sc&9P!WWHJJ ztTxa7Ms>OFn8;hjRlfIP3nXl;F$X}Wh7tnmXC!fu=1IeUD24b7xQn_{fovAO<)@I( zfBS@VrszCN{!f5}5|2?(qT657nizWkEj;H!yS(c4PvDHuMjC}_Yn6jH zH^crvmNDEgyR22u? zs$fME9|^H+6Jp|B3k>U`n|k7DW2*$F^fYUEodI{b*{~u-AUHFXKsQN7)qL;%a^?RE zf((808qU1%XfIsJ$WtX=Q5`{YQo}P<-+2JTGfbkcI*kJb7*I`WH6exT46Evex@>#e zgzP^Y?$Ryoqo5ROHn#48&6wWOW4Hn6KJd})b7%16a(a85DE)Y2W)ZBP)jzszng-=9svWuQ&&ixhR#a0T0Ru1*4PET6rU zX#4T}WrVCwZg1{J%EoU2a2QPFh#RKwg$Ve-%-*St02n!aj4VitTE1z?q6Md$5nwq5 zd62{Nla;@I6dt|+1P{cz-EKvDnZ0au`4rvxIY*LNU^IGpw(fsxE!KLYW!+izNp!LP zLu!tw(g#bJd?Vu;rrqsC$|_OEpWeweU5XV%tWH>1@uIe zZ*Bc9JO7Z)G>VPrrB{QMfwM9L60}C588Ea8T&?9eANu#yFT&>|-3`94cw$%iEQJGg zBTk2zHo_2lGtqyS(CVk@mZ;Fa?~1;hhip80b>i)y>ibXAb03_^xzr*)vwHfuw0GUU>)@nXe)mw(5IZhBj-`GJZg4-;&MZ^ z_5H+bmQ8E}V1@Y>Rj^RwdoN_IZ3-ME#W*W1}+eL2RVN_ujeNcfhlKa=m za~4}%*C&6lOc!DrPGr6k@u92{mgw@^Lsc|~6d%lylZy%-wAwipurA~?sU!QAvVn65 zia%een;l*&gF>S!9>!Wln6O zk9l8ZGouDz@ZI-Uwsjh$eQi8Gb_y$oJ;=RC`5e|~+>RGyf z0b(T>(!+5CF~J4_BS}E-5!%5TbnZ~mgBjpK;~b?v!g)V)3f%1uE$$aYdG7KG(B~}h z8<@Yi28I#3nm}yC$1JkUpc`5ICp9sC>WL-gn)Yc${m*LN0}9K}tfET`yW8YKeko>h z#ae%V+DJC%R#BcOj*!TCRW$3f6>q#3M-?xkhlGmvFC-QaW~x(dsZEW=_1)GwdTy&S zm(RJu*S4yZ5IpxU{}}9~wN0m?RukZpl|eys&MVXQ6e4}?DtzYQrvc!9V(|e2{gPpC zV0;5St#kl7$KrFymvA@whs@rOy;-cX+O&VO@bw=m{U>2sD_WeBehU{kJFl#u`i1en z7xzi%0z)tq@j`pTun2eXU>1{ind}!6HpH&bt+oJWMXtpq)^UN4;t^3nlr-!;-bQk* zT=njCq=^PO>#HY&Unej2ZU+9a;F*=S>f+57f5^8zHI(gB_)I|!Cr2{uyE~wd#7~N=XYx0-R;`BdG}{39FMm_?N`s7ab$7I1 z0H=xRYnIzy!@+35NR2V|J5wsqvEHYyKMw_Mh2~`NeYOWhR(W=IAji^^!7zVRslL($ zL2+GG`0@mUGVQI76v{Ud=f_$3Pdk8I^viI%hvA^ggHl?gUMZh@|!uf~NZsP~DR+Y4!VZq_{`cN-zz3 za$N`~+(3SND=#AT6aMKLybyo*Vi`whGFK(L$JQ(_QexC({qE45p@!fn{}mmHhAc zUhRy0ZrYS1-3^qT5o;-EL~qa)Y{d+hld_o);W@nWw?e-vKJAM4q~L#II%wDEEx}m+ z`;$BQI#A}liz_QUoQqvyq>Kp9rTUx|m$VUpSvgS(PZncVO6mxT+8OW|rblt$h$oNc zJcJ?=2(H6PMQDo`ib9t&x_tnn0toQ}Z@9Cq*F*h;zci!2E8^#mF$zVu8;8@3{;>xw zm_L%;nTyr44FwOJgjIh4gfL{!Wi3rUNp3p3NX~ArFT)_H(F*9Kj=`ezZloNy%Uz%zPNsL$u>6S*;lS4;oT*D~ zfal5^#Ic>)tan)%I|}vv#QmCr&sSGUYf+CNhn*p$ziGR$LROUWh9)#v91i zd*(wLq-P1}FFSu`W&j952=2p`U1Xv@lCv-a-*{foZaWAD+K+LRUO}c$#~hMo(mZ~+ z4>^R11y^~YZsed8gP@PL?kuvz}219P1wULT2a34b6Nx;XgBz;hNs)w9+|hVeAp zIG{B1=}Y4R39_J$*UX@UeA03*<;PFmsLJN_*U|D`wO)TL!u*D4A?LgMSZQ7<7@hn1 z70G*3y3!*}b(R-x)P>Mx&**jxNeRxO$vK$WaI%;lKB;+?RHJ(cu(^SkJLN5$y;m|w zhg}A#%*Z>T!08gbhh)uq9o(L%p919Zo^cETq#M``(p@Ub8`O|`6z0k4Y(!6tT+=)N zb8(r3Gjo5TUVr1&F8gm4D0cwDiI>JREzpslyBB-zt7X=68S&5A08Rmb7Eb;HH5eZp(j_@=oZetDm1NCcc z*QvE&)~5y5PvWoU2<*^Aw6`XJ(X#}!=iKJSfEd%yo|emczvDLf28h8FT5n*2hTwBG zZ0wYN+6;E1h!M-r!0D(-RC-_*65Cit4Wwla4*%h`Nb7CmLlD$}u!>6h23KcSOCV`) z)WU!5t48d)xH+D0QWZp2t9z1Th-=+lowdxGhuD(M&H6D+GfK8YEED!zGfw zGp%AYVcunQ^v>xV?4`1D{01D1!j^j-aiV`2Umw8lpV87De@imsTW{_Sc_W zcpAp?f20CM%m~)N3NPLyY@i!S!@mCGb{imZ3}Lv5fK_vBRgHw1h#<*hU>{V(W9}+@ z-q)U4n|kas_bqbr_s2$xVOUC%z-UFICf^BrR$neAcHkGH7YAB$Pd*gJ~jnt$UV zLT!uK$#nmMG%MIN-|Ns$Baud=HI1uB>^d|jYl|^6&dIfWyT&d~ehHnqV1=QElIac% zbfmh}bhqB^z|*{3iAU>gyn&y^d;@>wIr_qZ$Mqx1%v=I)vKN^gXLnk zKs*R$$`ms4D9BczxH-oChUlon_?GF^o>Hi{9b lLf&)>m{4>dHO5%;XO~i20m%<8x~@1L|G$Hve+%Yk4gh_BezgDq 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..76f3826 --- /dev/null +++ b/tests/src/system/legal_hold.rs @@ -0,0 +1,287 @@ +/* + * 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, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, +}; +use serde_json::{Value, json}; + +const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; + +impl Account { + async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) { + 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 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}"); + + // 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:?}"); + } +} + +/// 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 -- 2.54.0 From 318783f4446d36ca7a5b291aad1fbe5e5f8758a1 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 17:56:02 -0700 Subject: [PATCH 2/8] Legal holds, step 2: who a hold covers A hold reaches an account by name, through any of its addresses' domains, its groups or its tenant, as they are now, so an account added to a held domain later is held too. An account that leaves a held domain, group or tenant stays held: the registry write hook adds it to the hold by name on every account change, whoever makes it (LH-2). Server::holds_on answers for the deletion paths, from the store each time so a hold binds every node at once. --- crates/common/src/audit.rs | 15 ++++++ crates/common/src/hold.rs | 43 +++++++++++++++ crates/common/src/lib.rs | 1 + crates/features/src/hold/mod.rs | 92 +++++++++++++++++++++++++++++++++ tests/src/system/legal_hold.rs | 51 ++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 crates/common/src/hold.rs diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 5eac3aa..1cfeb90 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, }; @@ -495,6 +496,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/hold.rs b/crates/common/src/hold.rs new file mode 100644 index 0000000..58be497 --- /dev/null +++ b/crates/common/src/hold.rs @@ -0,0 +1,43 @@ +/* + * 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 inbuxa_features::hold::{self, Hold, Member}; + +impl Server { + /// 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 + } + + /// 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/features/src/hold/mod.rs b/crates/features/src/hold/mod.rs index 1792e82..b940b47 100644 --- a/crates/features/src/hold/mod.rs +++ b/crates/features/src/hold/mod.rs @@ -18,6 +18,7 @@ //! //! 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, @@ -83,6 +84,48 @@ impl Scope { } } +/// 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")] @@ -300,6 +343,33 @@ pub async fn create(data: &Store, hold: &Hold) -> trc::Result { } } +/// 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(()) +} + /// Replaces a hold that `check_update` allowed. pub async fn update(data: &Store, hold: &Hold) -> trc::Result<()> { let mut batch = BatchBuilder::new(); @@ -419,6 +489,28 @@ mod tests { 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 stored_as_json() { let current = hold(accounts(&[2]), Some(100), None); diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index 76f3826..f6f524e 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -215,6 +215,57 @@ pub async fn test(test: &mut TestServer) { .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" + ); + // 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}}})) -- 2.54.0 From 7b97efbb7fd2e401cd140b21d059fd4b3a78af96 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:19:00 -0700 Subject: [PATCH 3/8] Legal holds, step 3: deleted items in a held account are kept Every way of deleting mail (JMAP, IMAP EXPUNGE, POP3, mailbox removal, Trash emptying) and Sieve scripts, events, contacts and files now asks how the account's deletions are kept: a hold keeps them with no expiry (archivedUntil 9999-12-31), even with undelete off; otherwise undelete's period applies as before (LH-4). A hold's date range decides by the item's own date (LH-3). Mail is noted as held at deletion and settled when it's archived, once its received date is known; outside the range it gets undelete's deadline or isn't kept. Events go by their start, with a day's slack for time zones; recurring events, contacts, files and scripts are held whole. A groupware item's note now stays until its archive succeeds, and a failure retries the task instead of being logged and lost (LH-5). --- crates/common/src/hold.rs | 11 +- crates/email/src/mailbox/destroy.rs | 10 +- crates/email/src/message/delete.rs | 10 +- crates/email/src/sieve/delete.rs | 12 +-- crates/features/src/hold/mod.rs | 100 ++++++++++++++++++ crates/features/src/undelete/data.rs | 8 ++ crates/features/src/undelete/email.rs | 44 ++++++-- crates/features/src/undelete/groupware.rs | 33 ++++++ crates/imap/src/op/expunge.rs | 10 +- crates/services/src/task_manager/index.rs | 35 +++++-- tests/src/system/legal_hold.rs | 119 +++++++++++++++++++++- 11 files changed, 350 insertions(+), 42 deletions(-) diff --git a/crates/common/src/hold.rs b/crates/common/src/hold.rs index 58be497..f92815e 100644 --- a/crates/common/src/hold.rs +++ b/crates/common/src/hold.rs @@ -10,7 +10,7 @@ //! there are few holds. use crate::Server; -use inbuxa_features::hold::{self, Hold, Member}; +use inbuxa_features::hold::{self, Hold, Keeping, Member}; impl Server { /// The active holds covering `account_id`, through its own name, its @@ -36,6 +36,15 @@ impl Server { 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?)) + } + /// 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/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 index b940b47..4480dde 100644 --- a/crates/features/src/hold/mod.rs +++ b/crates/features/src/hold/mod.rs @@ -26,6 +26,85 @@ use store::{ }; 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'; @@ -511,6 +590,27 @@ mod tests { 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); diff --git a/crates/features/src/undelete/data.rs b/crates/features/src/undelete/data.rs index c398800..b170c98 100644 --- a/crates/features/src/undelete/data.rs +++ b/crates/features/src/undelete/data.rs @@ -123,6 +123,14 @@ pub struct EmailNote { pub size: u64, pub mailboxes: Vec, 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). 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/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/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/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index f6f524e..12b139f 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -14,11 +14,22 @@ use crate::utils::{ }; use registry::schema::{ prelude::{ObjectType, Property}, - structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, + structs::{ + CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant, + UserRoles, + }, }; use serde_json::{Value, json}; +use types::id::Id; -const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; +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) { @@ -38,6 +49,13 @@ impl Account { 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]})) @@ -266,6 +284,86 @@ pub async fn test(test: &mut TestServer) { "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; + assert!(response["created"]["w"]["id"].is_string(), "LH-1: {response}"); + 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-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}}})) @@ -319,6 +417,23 @@ pub async fn test(test: &mut TestServer) { } } +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")] -- 2.54.0 From 8d3e99bc0053cdd601ca33209ef0f5205f2f08fa Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:32:45 -0700 Subject: [PATCH 4/8] Legal holds, step 4: freezing, release, and the audit log Placing or widening a hold freezes what's already archived in its scope and range, its old deadline noted; releasing one gives each item no other hold covers that deadline back, or release plus 30 days if later. One pass over the archive does both and changes nothing twice (LH-6, LH-10, LH-11). A held archived item can't be destroyed; restoring still can, and the hold is named only to callers who may see holds (LH-7). Audit records about a held account survive the purge (AU-7). Fixes the daily clean-up of expired archived items (UD-13), which never found any: the registry's unfiltered query reads an all-ids index that archived items aren't in. Items are now walked account by account, kept deleted accounts included. Expired items were still removed whenever their account's archive was read. --- crates/common/src/audit.rs | 11 ++- crates/common/src/hold.rs | 95 ++++++++++++++++++++++++- crates/features/src/hold/mod.rs | 35 +++++++++ crates/features/src/undelete/data.rs | 7 ++ crates/features/src/undelete/records.rs | 81 +++++++++++++++++++-- crates/jmap/src/inbuxa/legal_hold.rs | 5 ++ crates/jmap/src/inbuxa/undelete.rs | 27 +++++++ tests/src/system/legal_hold.rs | 88 ++++++++++++++++++++++- 8 files changed, 341 insertions(+), 8 deletions(-) diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 1cfeb90..488154b 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -449,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 } } diff --git a/crates/common/src/hold.rs b/crates/common/src/hold.rs index f92815e..c97dd56 100644 --- a/crates/common/src/hold.rs +++ b/crates/common/src/hold.rs @@ -10,7 +10,26 @@ //! there are few holds. use crate::Server; -use inbuxa_features::hold::{self, Hold, Keeping, Member}; +use ahash::AHashMap; +use inbuxa_features::{ + hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until}, + undelete::records, +}; +use registry::schema::{prelude::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, +} impl Server { /// The active holds covering `account_id`, through its own name, its @@ -45,6 +64,80 @@ impl Server { 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; + } + } + Ok(settled) + } + + /// 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/features/src/hold/mod.rs b/crates/features/src/hold/mod.rs index 4480dde..5815647 100644 --- a/crates/features/src/hold/mod.rs +++ b/crates/features/src/hold/mod.rs @@ -107,6 +107,7 @@ impl Keeping { 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; @@ -367,6 +368,40 @@ 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 diff --git a/crates/features/src/undelete/data.rs b/crates/features/src/undelete/data.rs index b170c98..de1a7e4 100644 --- a/crates/features/src/undelete/data.rs +++ b/crates/features/src/undelete/data.rs @@ -470,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/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/jmap/src/inbuxa/legal_hold.rs b/crates/jmap/src/inbuxa/legal_hold.rs index bd9e065..2d2ccb5 100644 --- a/crates/jmap/src/inbuxa/legal_hold.rs +++ b/crates/jmap/src/inbuxa/legal_hold.rs @@ -405,6 +405,11 @@ pub async fn set( 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, 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/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index 12b139f..7e265c8 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -33,7 +33,9 @@ const USING: &[&str] = &[ impl Account { async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) { - arguments["accountId"] = self.id_string().into(); + 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 @@ -364,6 +366,90 @@ pub async fn test(test: &mut TestServer) { "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" + ); + + 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}" + ); + // 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}}})) -- 2.54.0 From 39707cd2e883518216d8ded7abfeee96941b2bfe Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:36:16 -0700 Subject: [PATCH 5/8] Legal holds, step 5: held accounts can't be destroyed Destroying a held account removes the login, as offboarding needs, but keeps its data as a deleted account with no expiry, whether or not undelete keeps accounts; its addresses stay reserved and its holds name it from then on. Destroy-now refuses it, and its DestroyAccount task defers itself while it's held or its time hasn't come. Holds placed or released later freeze or free kept accounts in the same settle pass, with 30 days' grace after the last release (LH-8, LH-10). --- crates/common/src/hold.rs | 56 ++++++++++++++++++- crates/features/src/hold/mod.rs | 13 +++++ crates/jmap/src/inbuxa/deleted_account.rs | 38 +++++++++++-- .../src/task_manager/destroy_account.rs | 17 ++++++ tests/src/system/legal_hold.rs | 51 ++++++++++++++++- 5 files changed, 168 insertions(+), 7 deletions(-) diff --git a/crates/common/src/hold.rs b/crates/common/src/hold.rs index c97dd56..cfa7325 100644 --- a/crates/common/src/hold.rs +++ b/crates/common/src/hold.rs @@ -15,7 +15,14 @@ use inbuxa_features::{ hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until}, undelete::records, }; -use registry::schema::{prelude::ObjectType, structs::ArchivedItem}; +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; @@ -29,6 +36,21 @@ const RELEASE_GRACE: u64 = 30 * 86_400; 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, +} + +/// 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 { @@ -114,9 +136,41 @@ impl Server { 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> { diff --git a/crates/features/src/hold/mod.rs b/crates/features/src/hold/mod.rs index 5815647..cdee60f 100644 --- a/crates/features/src/hold/mod.rs +++ b/crates/features/src/hold/mod.rs @@ -484,6 +484,19 @@ pub async fn keep_moved(data: &Store, before: &Member, after: &Member) -> trc::R 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(); 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/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/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index 7e265c8..219f115 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -309,7 +309,10 @@ pub async fn test(test: &mut TestServer) { "r": {"name": "Matter 6002", "from": "2020-01-01T00:00:00Z", "to": "2020-12-31T23:59:59Z", "scope": {"accounts": [ranged.id_string()]}}}})) .await; - assert!(response["created"]["w"]["id"].is_string(), "LH-1: {response}"); + 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; @@ -450,6 +453,52 @@ pub async fn test(test: &mut TestServer) { "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}}})) -- 2.54.0 From 538ae107d7a5d6e4113b3f7afb8b81d2bae7f6b3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:39:19 -0700 Subject: [PATCH 6/8] Legal holds, step 6: what each hold keeps inbuxa:LegalHold/get answers accountsCovered, itemsHeld and sizeHeld when asked: the accounts a hold reaches now (deleted ones it keeps included) and the archived items it keeps, with their size. Worked out in one pass over accounts and archive, only for requests that name them. Held items stay out of the user's quota, as all archived copies do (LH-9). --- crates/common/src/hold.rs | 83 +++++++++++++++++++ .../src/object/inbuxa_legal_hold.rs | 11 +++ crates/jmap/src/inbuxa/legal_hold.rs | 26 +++++- tests/src/system/legal_hold.rs | 11 +++ 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/common/src/hold.rs b/crates/common/src/hold.rs index cfa7325..197e7ec 100644 --- a/crates/common/src/hold.rs +++ b/crates/common/src/hold.rs @@ -41,6 +41,14 @@ pub struct Settled { 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 { @@ -54,6 +62,81 @@ pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member { } 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. diff --git a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs index 657a8ef..8f9b9bd 100644 --- a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs +++ b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs @@ -45,6 +45,11 @@ pub enum LegalHoldProperty { 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)] @@ -77,6 +82,9 @@ impl Property for LegalHoldProperty { LegalHoldProperty::ReleasedAt => "releasedAt", LegalHoldProperty::ReleasedBy => "releasedBy", LegalHoldProperty::ReleaseReason => "releaseReason", + LegalHoldProperty::AccountsCovered => "accountsCovered", + LegalHoldProperty::ItemsHeld => "itemsHeld", + LegalHoldProperty::SizeHeld => "sizeHeld", } .into() } @@ -99,6 +107,9 @@ impl LegalHoldProperty { b"releasedAt" => LegalHoldProperty::ReleasedAt, b"releasedBy" => LegalHoldProperty::ReleasedBy, b"releaseReason" => LegalHoldProperty::ReleaseReason, + b"accountsCovered" => LegalHoldProperty::AccountsCovered, + b"itemsHeld" => LegalHoldProperty::ItemsHeld, + b"sizeHeld" => LegalHoldProperty::SizeHeld, ) } } diff --git a/crates/jmap/src/inbuxa/legal_hold.rs b/crates/jmap/src/inbuxa/legal_hold.rs index 2d2ccb5..b4fc1bb 100644 --- a/crates/jmap/src/inbuxa/legal_hold.rs +++ b/crates/jmap/src/inbuxa/legal_hold.rs @@ -9,7 +9,7 @@ //! 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}; +use common::{Server, auth::AccessToken, hold::HoldSummary}; use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope}; use jmap_proto::{ error::set::SetError, @@ -67,7 +67,7 @@ fn ids(list: &[u32]) -> LValue { ) } -fn to_value(hold: &Hold, properties: &[P]) -> LValue { +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 { @@ -99,6 +99,9 @@ fn to_value(hold: &Hold, properties: &[P]) -> LValue { .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); } @@ -119,10 +122,21 @@ pub async fn get( 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() + }; match ids { None => { for current in hold::all(data).await? { - response.list.push(to_value(¤t, &properties)); + response + .list + .push(to_value(¤t, &properties, summaries.get(¤t.id))); } } Some(ids) => { @@ -132,7 +146,11 @@ pub async fn get( .map(|id| hold::get(data, id)) { Some(found) => match found.await? { - Some(current) => response.list.push(to_value(¤t, &properties)), + 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), diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index 219f115..7e697b1 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -411,6 +411,17 @@ pub async fn test(test: &mut TestServer) { 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}"); let (_, response) = frozen .hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]})) -- 2.54.0 From 3217aae4e8ae19d4627b4b4a01f6a1babbdc4800 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:41:37 -0700 Subject: [PATCH 7/8] LegalHold/get takes coveringAccount Only the active holds covering one account, live or deleted and kept, through any route: for the console's Held badge (LH-14). --- .../src/object/inbuxa_legal_hold.rs | 23 ++++++++++++++++++- crates/jmap/src/inbuxa/legal_hold.rs | 19 +++++++++++++++ tests/src/system/legal_hold.rs | 13 +++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs index 8f9b9bd..aa2b1fc 100644 --- a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs +++ b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs @@ -139,6 +139,27 @@ impl Element for LegalHoldValue { } } +/// 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 { @@ -170,7 +191,7 @@ impl JmapObject for LegalHold { type Comparator = (); - type GetArguments = (); + type GetArguments = LegalHoldGetArguments; type SetArguments<'de> = LegalHoldSetArguments; diff --git a/crates/jmap/src/inbuxa/legal_hold.rs b/crates/jmap/src/inbuxa/legal_hold.rs index b4fc1bb..3cce717 100644 --- a/crates/jmap/src/inbuxa/legal_hold.rs +++ b/crates/jmap/src/inbuxa/legal_hold.rs @@ -131,6 +131,25 @@ pub async fn get( } 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? { diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index 7e697b1..e7716df 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -422,6 +422,19 @@ pub async fn test(test: &mut TestServer) { 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]})) -- 2.54.0 From c1b5bf956cb01ba71bd52a19a3115888f280fd7c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 19:28:39 -0700 Subject: [PATCH 8/8] Audit records name accounts in full, and holds by name An account's or mailing list's name is only its local part, so the log said "Account ken.gosling" where two domains could each have one; it now says ken.gosling@alpineski.test. A change to a legal hold was recorded under its id; the hold's current state is now read first, so the record carries its case name and each change reads before/after. --- crates/jmap/src/inbuxa/audit.rs | 64 +++++++++++++++++++++++++++++++-- tests/src/system/legal_hold.rs | 27 ++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) 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/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index e7716df..cd1d523 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -574,6 +574,33 @@ pub async fn test(test: &mut TestServer) { 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( -- 2.54.0