Legal holds (phase 3) #70
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub groups: Vec<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub domains: Vec<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tenants: Vec<u32>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
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<u64>,
|
||||
/// Seconds since the epoch. Items dated after aren't held (LH-3).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub to: Option<u64>,
|
||||
pub placed_at: u64,
|
||||
pub placed_by: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub placed_by_id: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub released: Option<Release>,
|
||||
}
|
||||
|
||||
/// 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>(T);
|
||||
|
||||
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||
serde_json::to_vec(&self.0).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to serialize legal hold")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: serde::de::DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
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<ValueClass> {
|
||||
ValueKey::from(class(id))
|
||||
}
|
||||
|
||||
/// One hold, released or not.
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Hold>>(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<Vec<Hold>> {
|
||||
let mut holds = Vec::new();
|
||||
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||
if let Ok(Json(hold)) = Json::<Hold>::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<Vec<Hold>> {
|
||||
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<u32> {
|
||||
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<u64>, to: Option<u64>) -> 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::<Hold>(&json).unwrap(), current);
|
||||
assert!(json.contains("\"scope\":{\"accounts\":[2]}"), "{json}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Item
|
||||
})
|
||||
.map(|lock| lock.account_id)
|
||||
}
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
collection::Collection,
|
||||
};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
const FEATURE: u8 = b'K';
|
||||
const KIND_LOCK: u8 = b'l';
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:LegalHold/get` and `/set` under `urn:inbuxa:jmap`: legal holds
|
||||
//! (audit-hold-lock spec, LH-1 to LH-14). Creating one places the hold;
|
||||
//! updating renames it, widens its range or scope, or releases it with
|
||||
//! `released: true`. There is no destroy: a released hold stays listed. The
|
||||
//! set call's `reason` argument says why, for the audit log (AU-12);
|
||||
//! creating takes it as a property too.
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LegalHold;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LegalHoldProperty {
|
||||
Id,
|
||||
/// The case name.
|
||||
Name,
|
||||
/// A matter or ticket number.
|
||||
Reference,
|
||||
Description,
|
||||
/// `{server, accounts, groups, domains, tenants}`.
|
||||
Scope,
|
||||
/// The range's start, a UTC date, or null.
|
||||
From,
|
||||
/// The range's end, a UTC date, or null.
|
||||
To,
|
||||
/// Why it was placed (create only; later reasons are the audit log's).
|
||||
Reason,
|
||||
PlacedAt,
|
||||
PlacedBy,
|
||||
/// Set to true to release it.
|
||||
Released,
|
||||
ReleasedAt,
|
||||
ReleasedBy,
|
||||
ReleaseReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LegalHoldValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for LegalHoldProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// 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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
LegalHoldProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LegalHoldValue {
|
||||
type Property = LegalHoldProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for LegalHoldSetArguments {
|
||||
fn deserialize_argument<A>(&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::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
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<Id> for LegalHoldValue {
|
||||
fn from(id: Id) -> Self {
|
||||
LegalHoldValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for LegalHoldValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
LegalHoldValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -119,6 +119,7 @@ pub enum GetRequestMethod {
|
||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
@@ -151,6 +152,7 @@ pub enum SetRequestMethod<'x> {
|
||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -106,6 +106,7 @@ pub enum GetResponseMethod {
|
||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
@@ -138,6 +139,7 @@ pub enum SetResponseMethod {
|
||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -765,3 +767,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
|
||||
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: legal hold
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -424,6 +424,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
|
||||
@@ -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<String>) -> 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<LegalHold>,
|
||||
) -> trc::Result<GetResponse<LegalHold>> {
|
||||
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<String> {
|
||||
reason
|
||||
.map(str::trim)
|
||||
.filter(|r| !r.is_empty())
|
||||
.map(|r| r.chars().take(MAX_TEXT).collect())
|
||||
}
|
||||
|
||||
fn reason_required() -> SetError<P> {
|
||||
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<P> {
|
||||
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<P> {
|
||||
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<Option<String>, SetError<P>> {
|
||||
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<Option<u64>, SetError<P>> {
|
||||
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<Scope, SetError<P>> {
|
||||
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<SetResponse<LegalHold>> {
|
||||
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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc
|
||||
-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM
|
||||
@@ -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("[email protected]");
|
||||
let custodian = admin
|
||||
.create_user_account("[email protected]", "custodian-secret-2201", "Custodian", &[], vec![])
|
||||
.await;
|
||||
let other = admin
|
||||
.create_user_account("[email protected]", "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(
|
||||
"[email protected]",
|
||||
"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::<Vec<_>>();
|
||||
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("[email protected]").await;
|
||||
test.insert_account(admin);
|
||||
self::test(&mut test).await;
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user