Legal holds (phase 3) #70
@@ -41,6 +41,14 @@ pub struct Settled {
|
||||
pub accounts_released: usize,
|
||||
}
|
||||
|
||||
/// What one hold keeps (LH-9).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HoldSummary {
|
||||
pub accounts: u64,
|
||||
pub items: u64,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// A kept account as it was when deleted, for a hold's scope: its record
|
||||
/// still names its domain, groups and tenant.
|
||||
pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member {
|
||||
@@ -54,6 +62,81 @@ pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// What decides whether a hold reaches a live account; None if it's gone.
|
||||
pub async fn member_of(&self, account_id: u32) -> Option<Member> {
|
||||
let account = self.account(account_id).await.ok()?;
|
||||
let mut domains = account
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|address| address.domain_id)
|
||||
.collect::<Vec<_>>();
|
||||
domains.sort_unstable();
|
||||
domains.dedup();
|
||||
Some(Member {
|
||||
account: account_id,
|
||||
domains,
|
||||
groups: account.id_member_of.iter().copied().collect(),
|
||||
tenant: account.id_tenant,
|
||||
})
|
||||
}
|
||||
|
||||
/// LH-9, the console's "what's held": per active hold, the accounts it
|
||||
/// covers now (deleted ones it keeps included), and the archived items
|
||||
/// it keeps with their size. One pass over accounts and archive.
|
||||
pub async fn hold_summaries(&self) -> trc::Result<AHashMap<u32, HoldSummary>> {
|
||||
let data = self.store();
|
||||
let registry = self.registry();
|
||||
let holds = hold::active(data).await?;
|
||||
let mut summaries: AHashMap<u32, HoldSummary> =
|
||||
holds.iter().map(|h| (h.id, HoldSummary::default())).collect();
|
||||
if holds.is_empty() {
|
||||
return Ok(summaries);
|
||||
}
|
||||
let mut members: AHashMap<u32, Member> = AHashMap::new();
|
||||
for id in registry
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
if let Some(member) = self.member_of(id.document_id()).await {
|
||||
members.insert(id.document_id(), member);
|
||||
}
|
||||
}
|
||||
for (account_id, kept) in undelete_data::kept_accounts(data).await? {
|
||||
members.insert(account_id, kept_member(account_id, &kept));
|
||||
}
|
||||
for member in members.values() {
|
||||
for hold in holds.iter().filter(|h| h.scope.covers(member)) {
|
||||
summaries.entry(hold.id).or_default().accounts += 1;
|
||||
}
|
||||
}
|
||||
for id in records::all(data, registry).await? {
|
||||
let Some(item) = registry.object::<ArchivedItem>(id).await? else {
|
||||
continue;
|
||||
};
|
||||
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
|
||||
continue;
|
||||
}
|
||||
let Some(member) = members.get(&item.account_id().document_id()) else {
|
||||
continue;
|
||||
};
|
||||
let size = match &item {
|
||||
ArchivedItem::Email(email) => email.size,
|
||||
ArchivedItem::FileNode(_) => match undelete_data::extra(data, id).await? {
|
||||
Some(inbuxa_features::undelete::data::Extra::FileNode { size, .. }) => size as u64,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
};
|
||||
for hold in holds.iter().filter(|h| h.scope.covers(member)) {
|
||||
let summary = summaries.entry(hold.id).or_default();
|
||||
summary.items += 1;
|
||||
summary.size += size;
|
||||
}
|
||||
}
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
/// The active holds covering `account_id`, through its own name, its
|
||||
/// addresses' domains, its groups or its tenant. Empty for an account
|
||||
/// that no longer exists: a deleted one is kept by LH-8's own check.
|
||||
|
||||
@@ -45,6 +45,11 @@ pub enum LegalHoldProperty {
|
||||
ReleasedAt,
|
||||
ReleasedBy,
|
||||
ReleaseReason,
|
||||
/// LH-9: accounts it covers now, deleted ones it keeps included.
|
||||
AccountsCovered,
|
||||
/// LH-9: archived items it keeps, and their size in bytes.
|
||||
ItemsHeld,
|
||||
SizeHeld,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -77,6 +82,9 @@ impl Property for LegalHoldProperty {
|
||||
LegalHoldProperty::ReleasedAt => "releasedAt",
|
||||
LegalHoldProperty::ReleasedBy => "releasedBy",
|
||||
LegalHoldProperty::ReleaseReason => "releaseReason",
|
||||
LegalHoldProperty::AccountsCovered => "accountsCovered",
|
||||
LegalHoldProperty::ItemsHeld => "itemsHeld",
|
||||
LegalHoldProperty::SizeHeld => "sizeHeld",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -99,6 +107,9 @@ impl LegalHoldProperty {
|
||||
b"releasedAt" => LegalHoldProperty::ReleasedAt,
|
||||
b"releasedBy" => LegalHoldProperty::ReleasedBy,
|
||||
b"releaseReason" => LegalHoldProperty::ReleaseReason,
|
||||
b"accountsCovered" => LegalHoldProperty::AccountsCovered,
|
||||
b"itemsHeld" => LegalHoldProperty::ItemsHeld,
|
||||
b"sizeHeld" => LegalHoldProperty::SizeHeld,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! 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 common::{Server, auth::AccessToken, hold::HoldSummary};
|
||||
use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
@@ -67,7 +67,7 @@ fn ids(list: &[u32]) -> LValue {
|
||||
)
|
||||
}
|
||||
|
||||
fn to_value(hold: &Hold, properties: &[P]) -> LValue {
|
||||
fn to_value(hold: &Hold, properties: &[P], summary: Option<&HoldSummary>) -> LValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
@@ -99,6 +99,9 @@ fn to_value(hold: &Hold, properties: &[P]) -> LValue {
|
||||
.released
|
||||
.as_ref()
|
||||
.map_or(Value::Null, |r| Value::Str(r.reason.clone().into())),
|
||||
P::AccountsCovered => Value::Number(summary.map_or(0, |s| s.accounts).into()),
|
||||
P::ItemsHeld => Value::Number(summary.map_or(0, |s| s.items).into()),
|
||||
P::SizeHeld => Value::Number(summary.map_or(0, |s| s.size).into()),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
@@ -119,10 +122,21 @@ pub async fn get(
|
||||
not_found,
|
||||
};
|
||||
let data = server.store();
|
||||
// LH-9: only when asked for, since it walks the archive
|
||||
let summaries = if properties
|
||||
.iter()
|
||||
.any(|p| matches!(p, P::AccountsCovered | P::ItemsHeld | P::SizeHeld))
|
||||
{
|
||||
server.hold_summaries().await?
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
match ids {
|
||||
None => {
|
||||
for current in hold::all(data).await? {
|
||||
response.list.push(to_value(¤t, &properties));
|
||||
response
|
||||
.list
|
||||
.push(to_value(¤t, &properties, summaries.get(¤t.id)));
|
||||
}
|
||||
}
|
||||
Some(ids) => {
|
||||
@@ -132,7 +146,11 @@ pub async fn get(
|
||||
.map(|id| hold::get(data, id))
|
||||
{
|
||||
Some(found) => match found.await? {
|
||||
Some(current) => response.list.push(to_value(¤t, &properties)),
|
||||
Some(current) => response.list.push(to_value(
|
||||
¤t,
|
||||
&properties,
|
||||
summaries.get(¤t.id),
|
||||
)),
|
||||
None => response.push_not_found(id),
|
||||
},
|
||||
None => response.push_not_found(id),
|
||||
|
||||
@@ -411,6 +411,17 @@ pub async fn test(test: &mut TestServer) {
|
||||
is_held(&archived(frozen.archived_items().await)),
|
||||
"test 6, LH-6: the archived item wasn't frozen"
|
||||
);
|
||||
// LH-9: what the hold keeps, for the console
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"inbuxa:LegalHold/get",
|
||||
json!({"ids": [first], "properties": ["accountsCovered", "itemsHeld", "sizeHeld"]}),
|
||||
)
|
||||
.await;
|
||||
let summary = &response["list"][0];
|
||||
assert_eq!(summary["accountsCovered"], 1, "LH-9: {response}");
|
||||
assert_eq!(summary["itemsHeld"], 1, "LH-9: {response}");
|
||||
assert!(summary["sizeHeld"].as_u64().is_some_and(|s| s > 0), "LH-9: {response}");
|
||||
|
||||
let (_, response) = frozen
|
||||
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||
|
||||
Reference in New Issue
Block a user