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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user