Legal holds (phase 3) #70
@@ -41,6 +41,14 @@ pub struct Settled {
|
|||||||
pub accounts_released: usize,
|
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
|
/// A kept account as it was when deleted, for a hold's scope: its record
|
||||||
/// still names its domain, groups and tenant.
|
/// still names its domain, groups and tenant.
|
||||||
pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member {
|
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 {
|
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
|
/// The active holds covering `account_id`, through its own name, its
|
||||||
/// addresses' domains, its groups or its tenant. Empty for an account
|
/// 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.
|
/// that no longer exists: a deleted one is kept by LH-8's own check.
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ pub enum LegalHoldProperty {
|
|||||||
ReleasedAt,
|
ReleasedAt,
|
||||||
ReleasedBy,
|
ReleasedBy,
|
||||||
ReleaseReason,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
@@ -77,6 +82,9 @@ impl Property for LegalHoldProperty {
|
|||||||
LegalHoldProperty::ReleasedAt => "releasedAt",
|
LegalHoldProperty::ReleasedAt => "releasedAt",
|
||||||
LegalHoldProperty::ReleasedBy => "releasedBy",
|
LegalHoldProperty::ReleasedBy => "releasedBy",
|
||||||
LegalHoldProperty::ReleaseReason => "releaseReason",
|
LegalHoldProperty::ReleaseReason => "releaseReason",
|
||||||
|
LegalHoldProperty::AccountsCovered => "accountsCovered",
|
||||||
|
LegalHoldProperty::ItemsHeld => "itemsHeld",
|
||||||
|
LegalHoldProperty::SizeHeld => "sizeHeld",
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,9 @@ impl LegalHoldProperty {
|
|||||||
b"releasedAt" => LegalHoldProperty::ReleasedAt,
|
b"releasedAt" => LegalHoldProperty::ReleasedAt,
|
||||||
b"releasedBy" => LegalHoldProperty::ReleasedBy,
|
b"releasedBy" => LegalHoldProperty::ReleasedBy,
|
||||||
b"releaseReason" => LegalHoldProperty::ReleaseReason,
|
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
|
//! this: the tenant ceiling strips the permissions from everyone in a
|
||||||
//! tenant (LH-13). What a hold keeps is the undelete hooks' job.
|
//! 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 inbuxa_features::hold::{self, Hold, Refusal, Release, Scope};
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
error::set::SetError,
|
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());
|
let mut out = Map::with_capacity(properties.len());
|
||||||
for property in properties {
|
for property in properties {
|
||||||
let value = match property {
|
let value = match property {
|
||||||
@@ -99,6 +99,9 @@ fn to_value(hold: &Hold, properties: &[P]) -> LValue {
|
|||||||
.released
|
.released
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(Value::Null, |r| Value::Str(r.reason.clone().into())),
|
.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);
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
}
|
}
|
||||||
@@ -119,10 +122,21 @@ pub async fn get(
|
|||||||
not_found,
|
not_found,
|
||||||
};
|
};
|
||||||
let data = server.store();
|
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 {
|
match ids {
|
||||||
None => {
|
None => {
|
||||||
for current in hold::all(data).await? {
|
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) => {
|
Some(ids) => {
|
||||||
@@ -132,7 +146,11 @@ pub async fn get(
|
|||||||
.map(|id| hold::get(data, id))
|
.map(|id| hold::get(data, id))
|
||||||
{
|
{
|
||||||
Some(found) => match found.await? {
|
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),
|
||||||
},
|
},
|
||||||
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)),
|
is_held(&archived(frozen.archived_items().await)),
|
||||||
"test 6, LH-6: the archived item wasn't frozen"
|
"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
|
let (_, response) = frozen
|
||||||
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||||
|
|||||||
Reference in New Issue
Block a user