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
+11
View File
@@ -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
+36
View File
@@ -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)
+1
View File
@@ -424,6 +424,7 @@ impl IntermediateChangesResponse {
| MethodObject::AuditExport
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(),
+417
View File
@@ -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(&current, &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(&current, &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)
}
+1
View File
@@ -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;