Legal holds, step 1: the hold itself

inbuxa:LegalHold get/set places a hold on accounts, groups, domains,
tenants or the whole server, with an optional date range. A hold's range
and scope can only widen, a released hold is read-only, and none is ever
deleted. Placing, changing and releasing each need a reason and are
audited (LH-1, LH-3, LH-10, AU-12).

Permissions 669-672 (see, place, widen or release, export held data)
go to server administrators only; the tenant ceiling always strips them,
as it does Impersonate (LH-13). Schema: Compliance > Legal Holds.

What a hold keeps comes next, through the undelete hooks.

Also moves the lock expiry helpers below the lock module's imports.
This commit is contained in:
2026-09-27 19:33:06 -07:00
parent 621ebdff74
commit 5d2e35b2dc
24 changed files with 1502 additions and 10 deletions
+287
View File
@@ -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();
}
}
+1
View File
@@ -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