Legal holds, step 5: held accounts can't be destroyed

Destroying a held account removes the login, as offboarding needs, but
keeps its data as a deleted account with no expiry, whether or not
undelete keeps accounts; its addresses stay reserved and its holds name
it from then on. Destroy-now refuses it, and its DestroyAccount task
defers itself while it's held or its time hasn't come. Holds placed or
released later freeze or free kept accounts in the same settle pass,
with 30 days' grace after the last release (LH-8, LH-10).
This commit is contained in:
2026-09-27 19:33:07 -07:00
parent 8d3e99bc00
commit 39707cd2e8
5 changed files with 168 additions and 7 deletions
+55 -1
View File
@@ -15,7 +15,14 @@ use inbuxa_features::{
hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until},
undelete::records,
};
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
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;
@@ -29,6 +36,21 @@ const RELEASE_GRACE: u64 = 30 * 86_400;
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,
}
/// 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 {
@@ -114,9 +136,41 @@ impl Server {
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>> {