Legal holds, step 4: freezing, release, and the audit log
Placing or widening a hold freezes what's already archived in its scope and range, its old deadline noted; releasing one gives each item no other hold covers that deadline back, or release plus 30 days if later. One pass over the archive does both and changes nothing twice (LH-6, LH-10, LH-11). A held archived item can't be destroyed; restoring still can, and the hold is named only to callers who may see holds (LH-7). Audit records about a held account survive the purge (AU-7). Fixes the daily clean-up of expired archived items (UD-13), which never found any: the registry's unfiltered query reads an all-ids index that archived items aren't in. Items are now walked account by account, kept deleted accounts included. Expired items were still removed whenever their account's archive was read.
This commit is contained in:
@@ -449,7 +449,16 @@ impl Server {
|
||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||
let settings = log::settings(self.store()).await?;
|
||||
let cutoff = ms().saturating_sub(settings.keep_for_secs.saturating_mul(1000));
|
||||
log::purge(self.store(), cutoff, |_| false).await
|
||||
// LH-6, AU-7: a record about a held account stays while it's held.
|
||||
// Worked out before the purge, which can't wait on lookups.
|
||||
let held = self.held_accounts().await?;
|
||||
log::purge(self.store(), cutoff, |record| {
|
||||
record
|
||||
.target
|
||||
.account_id
|
||||
.is_some_and(|account_id| held.contains(&account_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,26 @@
|
||||
//! there are few holds.
|
||||
|
||||
use crate::Server;
|
||||
use inbuxa_features::hold::{self, Hold, Keeping, Member};
|
||||
use ahash::AHashMap;
|
||||
use inbuxa_features::{
|
||||
hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until},
|
||||
undelete::records,
|
||||
};
|
||||
use registry::schema::{prelude::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,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// The active holds covering `account_id`, through its own name, its
|
||||
@@ -45,6 +64,80 @@ impl Server {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// 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())
|
||||
|
||||
Reference in New Issue
Block a user