From 5d2e35b2dc9e78dedaebe6899980a1fd04ee4137 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 17:46:30 -0700 Subject: [PATCH] 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 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 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