Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! inbuxa: which legal holds cover an account (audit-hold-lock spec, LH-2,
|
|
//! LH-11), for the paths that destroy data. Read from the store every time,
|
|
//! not cached: a hold placed on one node must bind every node at once, and
|
|
//! there are few holds.
|
|
|
|
use crate::Server;
|
|
use ahash::AHashMap;
|
|
use inbuxa_features::{
|
|
hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until},
|
|
undelete::records,
|
|
};
|
|
use inbuxa_features::undelete::data::{self as undelete_data, KeptAccount};
|
|
use registry::{
|
|
pickle::PickledStream,
|
|
schema::{
|
|
prelude::{ObjectInner, ObjectType},
|
|
structs::ArchivedItem,
|
|
},
|
|
};
|
|
use store::{registry::RegistryQuery, write::now};
|
|
use trc::AddContext;
|
|
use types::id::Id;
|
|
|
|
/// The grace a released item gets at least (LH-10): a release made in error
|
|
/// can be undone by placing a new hold within it.
|
|
const RELEASE_GRACE: u64 = 30 * 86_400;
|
|
|
|
/// What a settle pass changed.
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Settled {
|
|
pub frozen: usize,
|
|
pub released: usize,
|
|
/// Deleted accounts kept by a hold, or let go by a release (LH-8, LH-10).
|
|
pub accounts_frozen: 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
|
|
/// still names its domain, groups and tenant.
|
|
pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member {
|
|
PickledStream::new(&kept.record)
|
|
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Account, &mut stream))
|
|
.and_then(|inner| Member::of(account_id, &inner))
|
|
.unwrap_or(Member {
|
|
account: account_id,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
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.
|
|
pub async fn holds_on(&self, account_id: u32) -> trc::Result<Vec<Hold>> {
|
|
let Ok(account) = self.account(account_id).await else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut domains = account
|
|
.addresses
|
|
.iter()
|
|
.map(|address| address.domain_id)
|
|
.collect::<Vec<_>>();
|
|
domains.sort_unstable();
|
|
domains.dedup();
|
|
let member = Member {
|
|
account: account_id,
|
|
domains,
|
|
groups: account.id_member_of.iter().copied().collect(),
|
|
tenant: account.id_tenant,
|
|
};
|
|
hold::covering(self.store(), &member).await
|
|
}
|
|
|
|
/// How `account_id`'s deleted items are kept: its holds' ranges and the
|
|
/// undelete period in force now (LH-4, UD-6a).
|
|
pub async fn keeping(&self, account_id: u32) -> trc::Result<Keeping> {
|
|
let retention = inbuxa_features::undelete::settings::retention(self.registry())
|
|
.await?
|
|
.items;
|
|
Ok(Keeping::new(retention, &self.holds_on(account_id).await?))
|
|
}
|
|
|
|
/// LH-6, LH-10, LH-11: brings the whole archive in line with the active
|
|
/// holds. An archived item a hold covers is frozen (no deadline), its
|
|
/// old deadline noted; a frozen one no hold covers any more gets that
|
|
/// deadline back, or release plus 30 days if later. Run after every
|
|
/// change to a hold; it changes nothing twice.
|
|
pub async fn settle_archive(&self) -> trc::Result<Settled> {
|
|
let data = self.store();
|
|
let registry = self.registry();
|
|
let any_active = !hold::active(data).await?.is_empty();
|
|
let now = now();
|
|
let mut keeping: AHashMap<u32, Option<Keeping>> = AHashMap::new();
|
|
let mut settled = Settled::default();
|
|
for id in records::all(data, registry).await? {
|
|
let Some(item) = registry.object::<ArchivedItem>(id).await? else {
|
|
continue;
|
|
};
|
|
let account_id = item.account_id().document_id();
|
|
if !keeping.contains_key(&account_id) {
|
|
// An account that's gone can't be placed in a domain or
|
|
// tenant any more: None, and its items are left as they are
|
|
let known = self.account(account_id).await.is_ok();
|
|
let value = if known { Some(self.keeping(account_id).await?) } else { None };
|
|
keeping.insert(account_id, value);
|
|
}
|
|
let until = item.archived_until().timestamp().max(0) as u64;
|
|
let held = is_held_until(until);
|
|
let covered = match keeping.get(&account_id).and_then(Option::as_ref) {
|
|
Some(keeping) => match &item {
|
|
ArchivedItem::Email(email) => {
|
|
keeping.covers(Some(email.received_at.timestamp().max(0) as u64))
|
|
}
|
|
ArchivedItem::CalendarEvent(event) => keeping
|
|
.covers_event(event.start_time.map(|t| t.timestamp().max(0) as u64)),
|
|
_ => keeping.covers(None),
|
|
},
|
|
// Gone: release only once no hold is active anywhere
|
|
None => held && any_active,
|
|
};
|
|
if covered && !held {
|
|
hold::set_original_deadline(data, id.id(), Some(until)).await?;
|
|
records::set_deadline(data, registry, id, &item, HELD_UNTIL).await?;
|
|
settled.frozen += 1;
|
|
} else if !covered && held {
|
|
let original = hold::original_deadline(data, id.id()).await?.unwrap_or(0);
|
|
records::set_deadline(data, registry, id, &item, original.max(now + RELEASE_GRACE))
|
|
.await?;
|
|
hold::set_original_deadline(data, id.id(), None).await?;
|
|
settled.released += 1;
|
|
}
|
|
}
|
|
|
|
// LH-8, LH-10: deleted accounts kept by undelete follow the holds
|
|
// too. Their DestroyAccount task defers itself while they're kept.
|
|
let retention = inbuxa_features::undelete::settings::retention(registry)
|
|
.await?
|
|
.accounts;
|
|
for (account_id, mut kept) in undelete_data::kept_accounts(data).await? {
|
|
let covered = !hold::covering(data, &kept_member(account_id, &kept)).await?.is_empty();
|
|
let held = is_held_until(kept.kept_until);
|
|
let until = if covered && !held {
|
|
settled.accounts_frozen += 1;
|
|
HELD_UNTIL
|
|
} else if !covered && held {
|
|
settled.accounts_released += 1;
|
|
(kept.deleted_at + retention.unwrap_or(0)).max(now + RELEASE_GRACE)
|
|
} else {
|
|
continue;
|
|
};
|
|
kept.kept_until = until;
|
|
let mut batch = store::write::BatchBuilder::new();
|
|
undelete_data::set_kept_account(&mut batch, account_id, &kept)?;
|
|
data.write(batch.build_all())
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
}
|
|
Ok(settled)
|
|
}
|
|
|
|
/// LH-8: whether a hold covers a deleted account undelete keeps.
|
|
pub async fn is_kept_held(&self, account_id: u32, kept: &KeptAccount) -> trc::Result<bool> {
|
|
Ok(!hold::covering(self.store(), &kept_member(account_id, kept))
|
|
.await?
|
|
.is_empty())
|
|
}
|
|
|
|
/// Every account an active hold covers now. Empty, without looking at
|
|
/// accounts, when nothing is held.
|
|
pub async fn held_accounts(&self) -> trc::Result<ahash::AHashSet<u32>> {
|
|
let mut held = ahash::AHashSet::new();
|
|
if hold::active(self.store()).await?.is_empty() {
|
|
return Ok(held);
|
|
}
|
|
for id in self
|
|
.registry()
|
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
let account_id = id.document_id();
|
|
if self.is_held(account_id).await? {
|
|
held.insert(account_id);
|
|
}
|
|
}
|
|
Ok(held)
|
|
}
|
|
|
|
/// Whether any active hold covers `account_id` at all.
|
|
pub async fn is_held(&self, account_id: u32) -> trc::Result<bool> {
|
|
Ok(!self.holds_on(account_id).await?.is_empty())
|
|
}
|
|
}
|