Legal holds, step 6: what each hold keeps
inbuxa:LegalHold/get answers accountsCovered, itemsHeld and sizeHeld when asked: the accounts a hold reaches now (deleted ones it keeps included) and the archived items it keeps, with their size. Worked out in one pass over accounts and archive, only for requests that name them. Held items stay out of the user's quota, as all archived copies do (LH-9).
This commit is contained in:
@@ -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