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 a855484..bc1c4ef 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index ffa25a9..cf8d413 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc \ No newline at end of file +-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM \ No newline at end of file diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs new file mode 100644 index 0000000..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