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.
773 lines
27 KiB
Rust
773 lines
27 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! Legal holds (audit-hold-lock spec, LH-1 to LH-14).
|
|
//!
|
|
//! A hold names a case and what it covers: accounts, groups, domains,
|
|
//! tenants or the whole server, optionally only items dated inside a range.
|
|
//! While any active hold covers an item, nothing may destroy it. A hold is
|
|
//! never deleted: releasing it keeps it, read-only, for the audit trail.
|
|
//!
|
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
|
//! with `H`, then one byte for the kind:
|
|
//!
|
|
//! - `h` + hold id (u32): the hold, as JSON.
|
|
//!
|
|
//! Numbers are big-endian. There are few holds, so they're read whole.
|
|
|
|
use registry::schema::{prelude::ObjectInner, structs::Account};
|
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
|
use store::{
|
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
|
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
|
};
|
|
use trc::AddContext;
|
|
|
|
/// The deadline a held archived item carries: the last second of 9999. It
|
|
/// never passes, so every expiry check keeps the item without knowing about
|
|
/// holds (LH-4, LH-5); releasing a hold gives it a real deadline (LH-10).
|
|
pub const HELD_UNTIL: u64 = 253_402_300_799;
|
|
|
|
/// Whether an archived item's deadline marks it as held. Anything past the
|
|
/// year 9000 counts, so a deadline computed from a hold a moment earlier or
|
|
/// later still reads as held.
|
|
pub fn is_held_until(until: u64) -> bool {
|
|
until >= 221_845_392_000
|
|
}
|
|
|
|
/// A day, in seconds: the slack either side of a range for an event's start,
|
|
/// whose time zone isn't known here.
|
|
const DAY: u64 = 86_400;
|
|
|
|
/// How an account's deleted items are kept: its holds' ranges, and the
|
|
/// undelete period for whatever no hold covers (LH-3, LH-4).
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Keeping {
|
|
/// `archiveDeletedItemsFor`, in seconds, if undelete is on.
|
|
pub retention: Option<u64>,
|
|
/// Each active hold's range on this account; `(None, None)` is a whole
|
|
/// account. Empty when nothing holds it.
|
|
pub ranges: Vec<(Option<u64>, Option<u64>)>,
|
|
}
|
|
|
|
impl Keeping {
|
|
pub fn new(retention: Option<u64>, holds: &[Hold]) -> Keeping {
|
|
Keeping {
|
|
retention,
|
|
ranges: holds.iter().map(|h| (h.from, h.to)).collect(),
|
|
}
|
|
}
|
|
|
|
/// Whether any hold reaches the account at all.
|
|
pub fn is_held(&self) -> bool {
|
|
!self.ranges.is_empty()
|
|
}
|
|
|
|
/// Whether deleted items need noting: something may keep them.
|
|
pub fn keeps_anything(&self) -> bool {
|
|
self.is_held() || self.retention.is_some()
|
|
}
|
|
|
|
/// Whether a hold covers an item dated `date`. No date means the item is
|
|
/// held whole, whatever the range (LH-3).
|
|
pub fn covers(&self, date: Option<u64>) -> bool {
|
|
self.ranges.iter().any(|(from, to)| match date {
|
|
None => true,
|
|
Some(at) => {
|
|
from.is_none_or(|from| at >= from) && to.is_none_or(|to| at <= to)
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Like `covers`, for an event's start: a day of slack either side, since
|
|
/// its time zone isn't known here.
|
|
pub fn covers_event(&self, start: Option<u64>) -> bool {
|
|
self.ranges.iter().any(|(from, to)| match start {
|
|
None => true,
|
|
Some(at) => {
|
|
from.is_none_or(|from| at + DAY >= from)
|
|
&& to.is_none_or(|to| at <= to.saturating_add(DAY))
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Until when an item deleted at `now` is kept: held, the undelete
|
|
/// period, or not at all.
|
|
pub fn until(&self, now: u64, held: bool) -> Option<u64> {
|
|
if held {
|
|
Some(HELD_UNTIL)
|
|
} else {
|
|
self.retention.map(|retention| now + retention)
|
|
}
|
|
}
|
|
}
|
|
|
|
const FEATURE: u8 = b'H';
|
|
const KIND_HOLD: u8 = b'h';
|
|
const KIND_ORIGINAL: u8 = b'o';
|
|
const KIND_EXPORT: u8 = b'e';
|
|
|
|
/// How far a hold export has got (LH-12).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum ExportStatus {
|
|
Running,
|
|
Ready,
|
|
Failed,
|
|
}
|
|
|
|
/// A collection of what a hold keeps, as a ZIP (LH-12).
|
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Export {
|
|
pub id: u32,
|
|
pub hold_id: u32,
|
|
/// The accounts asked for; empty for every account the hold covers.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub accounts: Vec<u32>,
|
|
pub reason: String,
|
|
pub created_at: u64,
|
|
pub created_by: String,
|
|
/// Whose blob the ZIP is, so only they download it.
|
|
pub created_by_id: u32,
|
|
pub status: ExportStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub finished_at: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub blob_id: Option<String>,
|
|
#[serde(default)]
|
|
pub size: u64,
|
|
#[serde(default)]
|
|
pub items: u64,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub sha256: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// How many times creating a hold retries when another node took its id.
|
|
const CREATE_ATTEMPTS: usize = 5;
|
|
|
|
/// What a hold covers (LH-1, LH-2). Domains and tenants are resolved live,
|
|
/// so an account added to one later is held too.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Scope {
|
|
/// Every account on the server.
|
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
pub server: bool,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub accounts: Vec<u32>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub groups: Vec<u32>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub domains: Vec<u32>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tenants: Vec<u32>,
|
|
}
|
|
|
|
impl Scope {
|
|
pub fn is_empty(&self) -> bool {
|
|
!self.server
|
|
&& self.accounts.is_empty()
|
|
&& self.groups.is_empty()
|
|
&& self.domains.is_empty()
|
|
&& self.tenants.is_empty()
|
|
}
|
|
|
|
/// Whether this scope covers everything `other` does, entry by entry.
|
|
/// A scope may only grow (LH-3's rule for ranges, applied to scope):
|
|
/// taking something out would free what it held.
|
|
pub fn contains(&self, other: &Scope) -> bool {
|
|
let all = |mine: &[u32], theirs: &[u32]| theirs.iter().all(|id| mine.contains(id));
|
|
(self.server || !other.server)
|
|
&& all(&self.accounts, &other.accounts)
|
|
&& all(&self.groups, &other.groups)
|
|
&& all(&self.domains, &other.domains)
|
|
&& all(&self.tenants, &other.tenants)
|
|
}
|
|
|
|
fn normalize(&mut self) {
|
|
for list in [
|
|
&mut self.accounts,
|
|
&mut self.groups,
|
|
&mut self.domains,
|
|
&mut self.tenants,
|
|
] {
|
|
list.sort_unstable();
|
|
list.dedup();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What decides whether a hold's scope reaches an account: the domains of
|
|
/// its addresses, its groups and its tenant (LH-2).
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct Member {
|
|
pub account: u32,
|
|
pub domains: Vec<u32>,
|
|
pub groups: Vec<u32>,
|
|
pub tenant: Option<u32>,
|
|
}
|
|
|
|
impl Member {
|
|
/// A person's account as the registry stores it; `None` for a group,
|
|
/// whose own data is held through its members.
|
|
pub fn of(account_id: u32, object: &ObjectInner) -> Option<Member> {
|
|
let ObjectInner::Account(Account::User(user)) = object else {
|
|
return None;
|
|
};
|
|
let mut domains = vec![user.domain_id.document_id()];
|
|
domains.extend(user.aliases.iter().map(|alias| alias.domain_id.document_id()));
|
|
domains.sort_unstable();
|
|
domains.dedup();
|
|
Some(Member {
|
|
account: account_id,
|
|
domains,
|
|
groups: user.member_group_ids.iter().map(|id| id.document_id()).collect(),
|
|
tenant: user.member_tenant_id.map(|id| id.document_id()),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Scope {
|
|
/// Whether this scope reaches `member`, directly or through its domains,
|
|
/// groups or tenant, as they are now (LH-2).
|
|
pub fn covers(&self, member: &Member) -> bool {
|
|
self.server
|
|
|| self.accounts.contains(&member.account)
|
|
|| member.domains.iter().any(|d| self.domains.contains(d))
|
|
|| member.groups.iter().any(|g| self.groups.contains(g))
|
|
|| member.tenant.is_some_and(|t| self.tenants.contains(&t))
|
|
}
|
|
}
|
|
|
|
/// When and why a hold was released (LH-10).
|
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Release {
|
|
pub at: u64,
|
|
pub by: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub by_id: Option<u32>,
|
|
pub reason: String,
|
|
}
|
|
|
|
/// A legal hold (LH-1).
|
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Hold {
|
|
pub id: u32,
|
|
/// The case name.
|
|
pub name: String,
|
|
/// A matter or ticket number.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub reference: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
pub scope: Scope,
|
|
/// Seconds since the epoch. Items dated before aren't held (LH-3).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub from: Option<u64>,
|
|
/// Seconds since the epoch. Items dated after aren't held (LH-3).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub to: Option<u64>,
|
|
pub placed_at: u64,
|
|
pub placed_by: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub placed_by_id: Option<u32>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub released: Option<Release>,
|
|
}
|
|
|
|
/// Why a change to a hold is refused.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Refusal {
|
|
/// A released hold is read-only (LH-1).
|
|
Released,
|
|
/// The range may only widen (LH-3).
|
|
Narrowed,
|
|
/// The scope may only grow.
|
|
ScopeShrunk,
|
|
/// A hold has to cover something.
|
|
EmptyScope,
|
|
/// `from` after `to`.
|
|
Backwards,
|
|
}
|
|
|
|
impl Refusal {
|
|
pub fn describe(self) -> &'static str {
|
|
match self {
|
|
Refusal::Released => "A released hold can't be changed; place a new one instead.",
|
|
Refusal::Narrowed => {
|
|
"A hold's date range can only be widened. To hold less, release it and place a new hold."
|
|
}
|
|
Refusal::ScopeShrunk => {
|
|
"Nothing can be taken out of a hold's scope. To hold less, release it and place a new hold."
|
|
}
|
|
Refusal::EmptyScope => "A hold has to cover at least one account, group, domain or tenant, or the whole server.",
|
|
Refusal::Backwards => "The range starts after it ends.",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Hold {
|
|
pub fn is_active(&self) -> bool {
|
|
self.released.is_none()
|
|
}
|
|
|
|
/// Whether an item dated `at` (seconds) falls in the hold's range. With
|
|
/// no range, everything does (LH-3).
|
|
pub fn covers_date(&self, at: u64) -> bool {
|
|
self.from.is_none_or(|from| at >= from) && self.to.is_none_or(|to| at <= to)
|
|
}
|
|
|
|
/// Checks a new hold, and tidies its scope.
|
|
pub fn check_new(&mut self) -> Result<(), Refusal> {
|
|
self.scope.normalize();
|
|
if self.scope.is_empty() {
|
|
return Err(Refusal::EmptyScope);
|
|
}
|
|
if let (Some(from), Some(to)) = (self.from, self.to)
|
|
&& from > to
|
|
{
|
|
return Err(Refusal::Backwards);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Checks that `next` is an allowed change of `self`: names and notes
|
|
/// may change, the range may only widen, the scope may only grow, and a
|
|
/// released hold may not change at all.
|
|
pub fn check_update(&self, next: &mut Hold) -> Result<(), Refusal> {
|
|
if !self.is_active() {
|
|
return Err(Refusal::Released);
|
|
}
|
|
next.check_new()?;
|
|
// An open end can't be closed, and a set end can only move outward
|
|
let from_ok = match (self.from, next.from) {
|
|
(None, Some(_)) => false,
|
|
(Some(old), Some(new)) => new <= old,
|
|
(_, None) => true,
|
|
};
|
|
let to_ok = match (self.to, next.to) {
|
|
(None, Some(_)) => false,
|
|
(Some(old), Some(new)) => new >= old,
|
|
(_, None) => true,
|
|
};
|
|
if !from_ok || !to_ok {
|
|
return Err(Refusal::Narrowed);
|
|
}
|
|
if !next.scope.contains(&self.scope) {
|
|
return Err(Refusal::ScopeShrunk);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct Json<T>(T);
|
|
|
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
|
serde_json::to_vec(&self.0).map_err(|err| {
|
|
trc::StoreEvent::UnexpectedError
|
|
.into_err()
|
|
.details("Failed to serialize legal hold")
|
|
.reason(err)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<T: serde::de::DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
|
trc::StoreEvent::DataCorruption
|
|
.into_err()
|
|
.details("Invalid legal hold")
|
|
.reason(err)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn class(id: u32) -> ValueClass {
|
|
let mut key = Vec::with_capacity(6);
|
|
key.push(FEATURE);
|
|
key.push(KIND_HOLD);
|
|
key.extend_from_slice(&id.to_be_bytes());
|
|
ValueClass::Any(AnyClass {
|
|
subspace: SUBSPACE_INBUXA,
|
|
key,
|
|
})
|
|
}
|
|
|
|
fn key(id: u32) -> ValueKey<ValueClass> {
|
|
ValueKey::from(class(id))
|
|
}
|
|
|
|
fn original_class(item_id: u64) -> ValueClass {
|
|
let mut key = Vec::with_capacity(10);
|
|
key.push(FEATURE);
|
|
key.push(KIND_ORIGINAL);
|
|
key.extend_from_slice(&item_id.to_be_bytes());
|
|
ValueClass::Any(AnyClass {
|
|
subspace: SUBSPACE_INBUXA,
|
|
key,
|
|
})
|
|
}
|
|
|
|
/// LH-10: an archived item's deadline from before a hold froze it, so a
|
|
/// release can give it back (or a later one). None for an item held from
|
|
/// its deletion, which never had one.
|
|
pub async fn original_deadline(data: &Store, item_id: u64) -> trc::Result<Option<u64>> {
|
|
data.get_value::<u64>(ValueKey::from(original_class(item_id)))
|
|
.await
|
|
.caused_by(trc::location!())
|
|
}
|
|
|
|
/// Notes (`Some`) or forgets (`None`) an item's deadline from before it
|
|
/// was frozen.
|
|
pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64>) -> trc::Result<()> {
|
|
let mut batch = BatchBuilder::new();
|
|
match until {
|
|
Some(until) => batch.set(original_class(item_id), until.to_be_bytes().to_vec()),
|
|
None => batch.clear(original_class(item_id)),
|
|
};
|
|
data.write(batch.build_all())
|
|
.await
|
|
.caused_by(trc::location!())
|
|
.map(|_| ())
|
|
}
|
|
|
|
fn export_class(id: u32) -> ValueClass {
|
|
let mut key = Vec::with_capacity(6);
|
|
key.push(FEATURE);
|
|
key.push(KIND_EXPORT);
|
|
key.extend_from_slice(&id.to_be_bytes());
|
|
ValueClass::Any(AnyClass {
|
|
subspace: SUBSPACE_INBUXA,
|
|
key,
|
|
})
|
|
}
|
|
|
|
/// Every hold export, oldest first.
|
|
pub async fn exports(data: &Store) -> trc::Result<Vec<Export>> {
|
|
let mut exports = Vec::new();
|
|
data.iterate(
|
|
IterateParams::new(ValueKey::from(export_class(0)), ValueKey::from(export_class(u32::MAX))),
|
|
|_, value| {
|
|
if let Ok(Json(export)) = Json::<Export>::deserialize(value) {
|
|
exports.push(export);
|
|
}
|
|
Ok(true)
|
|
},
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
Ok(exports)
|
|
}
|
|
|
|
/// Writes a new export under the next free id, which it returns.
|
|
pub async fn create_export(data: &Store, export: &Export) -> trc::Result<u32> {
|
|
let mut attempt = 0;
|
|
loop {
|
|
attempt += 1;
|
|
let id = exports(data).await?.iter().map(|e| e.id).max().unwrap_or(0) + 1;
|
|
let stored = Export {
|
|
id,
|
|
..export.clone()
|
|
};
|
|
let mut batch = BatchBuilder::new();
|
|
batch.assert_value(export_class(id), AssertValue::None);
|
|
batch.set(export_class(id), Json(&stored).serialize()?);
|
|
match data.write(batch.build_all()).await {
|
|
Ok(_) => return Ok(id),
|
|
Err(err)
|
|
if attempt < CREATE_ATTEMPTS
|
|
&& matches!(
|
|
err.as_ref(),
|
|
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
|
) => {}
|
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Saves an export's progress.
|
|
pub async fn update_export(data: &Store, export: &Export) -> trc::Result<()> {
|
|
let mut batch = BatchBuilder::new();
|
|
batch.set(export_class(export.id), Json(export).serialize()?);
|
|
data.write(batch.build_all())
|
|
.await
|
|
.caused_by(trc::location!())
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// One hold, released or not.
|
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
|
Ok(data
|
|
.get_value::<Json<Hold>>(key(id))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.map(|Json(hold)| hold))
|
|
}
|
|
|
|
/// Every hold, released ones included, oldest first.
|
|
pub async fn all(data: &Store) -> trc::Result<Vec<Hold>> {
|
|
let mut holds = Vec::new();
|
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
|
if let Ok(Json(hold)) = Json::<Hold>::deserialize(value) {
|
|
holds.push(hold);
|
|
}
|
|
Ok(true)
|
|
})
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
Ok(holds)
|
|
}
|
|
|
|
/// The holds still in force.
|
|
pub async fn active(data: &Store) -> trc::Result<Vec<Hold>> {
|
|
Ok(all(data).await?.into_iter().filter(Hold::is_active).collect())
|
|
}
|
|
|
|
/// Writes a new hold under the next free id, which it returns. Two nodes
|
|
/// placing holds at once can't take the same id: the key must be absent.
|
|
pub async fn create(data: &Store, hold: &Hold) -> trc::Result<u32> {
|
|
let mut attempt = 0;
|
|
loop {
|
|
attempt += 1;
|
|
let id = all(data).await?.iter().map(|h| h.id).max().unwrap_or(0) + 1;
|
|
let stored = Hold {
|
|
id,
|
|
..hold.clone()
|
|
};
|
|
let mut batch = BatchBuilder::new();
|
|
batch.assert_value(class(id), AssertValue::None);
|
|
batch.set(class(id), Json(&stored).serialize()?);
|
|
match data.write(batch.build_all()).await {
|
|
Ok(_) => return Ok(id),
|
|
Err(err)
|
|
if attempt < CREATE_ATTEMPTS
|
|
&& matches!(
|
|
err.as_ref(),
|
|
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
|
) => {}
|
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The active holds that reach `member` (LH-2, LH-11).
|
|
pub async fn covering(data: &Store, member: &Member) -> trc::Result<Vec<Hold>> {
|
|
Ok(active(data)
|
|
.await?
|
|
.into_iter()
|
|
.filter(|hold| hold.scope.covers(member))
|
|
.collect())
|
|
}
|
|
|
|
/// LH-2: an account a hold reached through its domain, group or tenant stays
|
|
/// held when it leaves them: it is added to the hold by name. Called for
|
|
/// every change to an account, so no move escapes a hold.
|
|
pub async fn keep_moved(data: &Store, before: &Member, after: &Member) -> trc::Result<()> {
|
|
if before == after {
|
|
return Ok(());
|
|
}
|
|
for mut hold in active(data).await? {
|
|
if hold.scope.covers(before) && !hold.scope.covers(after) {
|
|
hold.scope.accounts.push(after.account);
|
|
hold.scope.accounts.sort_unstable();
|
|
hold.scope.accounts.dedup();
|
|
update(data, &hold).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// LH-8: names `account_id` in every hold that reaches it, so a deleted
|
|
/// account, no longer in any domain or tenant, stays held.
|
|
pub async fn pin_account(data: &Store, member: &Member) -> trc::Result<()> {
|
|
for mut hold in covering(data, member).await? {
|
|
if !hold.scope.accounts.contains(&member.account) {
|
|
hold.scope.accounts.push(member.account);
|
|
hold.scope.accounts.sort_unstable();
|
|
update(data, &hold).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Replaces a hold that `check_update` allowed.
|
|
pub async fn update(data: &Store, hold: &Hold) -> trc::Result<()> {
|
|
let mut batch = BatchBuilder::new();
|
|
batch.set(class(hold.id), Json(hold).serialize()?);
|
|
data.write(batch.build_all())
|
|
.await
|
|
.caused_by(trc::location!())
|
|
.map(|_| ())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn hold(scope: Scope, from: Option<u64>, to: Option<u64>) -> Hold {
|
|
Hold {
|
|
id: 1,
|
|
name: "Matter 4411".into(),
|
|
reference: Some("4411".into()),
|
|
description: None,
|
|
scope,
|
|
from,
|
|
to,
|
|
placed_at: 10,
|
|
placed_by: "admin".into(),
|
|
placed_by_id: None,
|
|
released: None,
|
|
}
|
|
}
|
|
|
|
fn accounts(ids: &[u32]) -> Scope {
|
|
Scope {
|
|
accounts: ids.to_vec(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_hold_needs_a_scope_and_a_forward_range() {
|
|
assert_eq!(hold(Scope::default(), None, None).check_new(), Err(Refusal::EmptyScope));
|
|
assert_eq!(hold(accounts(&[2]), Some(20), Some(10)).check_new(), Err(Refusal::Backwards));
|
|
let mut ok = hold(accounts(&[3, 2, 3]), None, None);
|
|
assert_eq!(ok.check_new(), Ok(()));
|
|
assert_eq!(ok.scope.accounts, vec![2, 3], "sorted, once each");
|
|
}
|
|
|
|
#[test]
|
|
fn the_range_only_widens() {
|
|
let current = hold(accounts(&[2]), Some(100), Some(200));
|
|
let widened = |from, to| {
|
|
let mut next = hold(accounts(&[2]), from, to);
|
|
current.check_update(&mut next)
|
|
};
|
|
assert_eq!(widened(Some(50), Some(300)), Ok(()));
|
|
assert_eq!(widened(None, None), Ok(()), "opening both ends widens");
|
|
assert_eq!(widened(Some(150), Some(200)), Err(Refusal::Narrowed));
|
|
assert_eq!(widened(Some(100), Some(150)), Err(Refusal::Narrowed));
|
|
|
|
let open = hold(accounts(&[2]), None, None);
|
|
let mut closed = hold(accounts(&[2]), Some(1), None);
|
|
assert_eq!(open.check_update(&mut closed), Err(Refusal::Narrowed), "an open end stays open");
|
|
}
|
|
|
|
#[test]
|
|
fn the_scope_only_grows() {
|
|
let current = hold(
|
|
Scope {
|
|
accounts: vec![2],
|
|
domains: vec![7],
|
|
..Default::default()
|
|
},
|
|
None,
|
|
None,
|
|
);
|
|
let mut grown = hold(
|
|
Scope {
|
|
accounts: vec![2, 3],
|
|
domains: vec![7],
|
|
tenants: vec![1],
|
|
..Default::default()
|
|
},
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(current.check_update(&mut grown), Ok(()));
|
|
let mut shrunk = hold(accounts(&[2, 3]), None, None);
|
|
assert_eq!(current.check_update(&mut shrunk), Err(Refusal::ScopeShrunk));
|
|
|
|
let server = hold(Scope { server: true, ..Default::default() }, None, None);
|
|
let mut less = hold(accounts(&[2]), None, None);
|
|
assert_eq!(server.check_update(&mut less), Err(Refusal::ScopeShrunk));
|
|
}
|
|
|
|
#[test]
|
|
fn a_released_hold_is_read_only() {
|
|
let mut released = hold(accounts(&[2]), None, None);
|
|
released.released = Some(Release {
|
|
at: 50,
|
|
by: "admin".into(),
|
|
by_id: None,
|
|
reason: "Settled".into(),
|
|
});
|
|
let mut next = released.clone();
|
|
next.name = "Renamed".into();
|
|
assert_eq!(released.check_update(&mut next), Err(Refusal::Released));
|
|
assert!(!released.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn dates_in_range() {
|
|
let whole = hold(accounts(&[2]), None, None);
|
|
assert!(whole.covers_date(0) && whole.covers_date(u64::MAX));
|
|
let ranged = hold(accounts(&[2]), Some(100), Some(200));
|
|
assert!(ranged.covers_date(100) && ranged.covers_date(200));
|
|
assert!(!ranged.covers_date(99) && !ranged.covers_date(201));
|
|
let open_ended = hold(accounts(&[2]), Some(100), None);
|
|
assert!(open_ended.covers_date(u64::MAX), "no `to` also catches mail still to come");
|
|
}
|
|
|
|
#[test]
|
|
fn a_scope_reaches_members_through_domain_group_and_tenant() {
|
|
let member = Member {
|
|
account: 9,
|
|
domains: vec![3, 4],
|
|
groups: vec![20],
|
|
tenant: Some(7),
|
|
};
|
|
let reaches = |scope: Scope| scope.covers(&member);
|
|
assert!(reaches(accounts(&[9])));
|
|
assert!(reaches(Scope { domains: vec![4], ..Default::default() }), "an alias's domain counts");
|
|
assert!(reaches(Scope { groups: vec![20], ..Default::default() }));
|
|
assert!(reaches(Scope { tenants: vec![7], ..Default::default() }));
|
|
assert!(reaches(Scope { server: true, ..Default::default() }));
|
|
assert!(!reaches(Scope { domains: vec![5], tenants: vec![8], ..Default::default() }));
|
|
|
|
// LH-2: leaving the held domain would free it, so the hold must name it
|
|
let held = hold(Scope { domains: vec![3], ..Default::default() }, None, None);
|
|
let moved = Member { domains: vec![6], ..member.clone() };
|
|
assert!(held.scope.covers(&member) && !held.scope.covers(&moved));
|
|
}
|
|
|
|
#[test]
|
|
fn keeping_deleted_items() {
|
|
let whole = Keeping::new(None, &[hold(accounts(&[2]), None, None)]);
|
|
assert!(whole.covers(Some(5)) && whole.covers(None));
|
|
assert_eq!(whole.until(100, whole.covers(Some(5))), Some(HELD_UNTIL));
|
|
assert!(is_held_until(whole.until(100, true).unwrap()));
|
|
|
|
// LH-3: a range holds only what's inside it; outside, undelete's rules
|
|
let ranged = Keeping::new(Some(30), &[hold(accounts(&[2]), Some(1_000), Some(2_000))]);
|
|
assert!(ranged.covers(Some(1_500)) && !ranged.covers(Some(2_500)));
|
|
assert!(ranged.covers(None), "contacts, files and scripts are held whole");
|
|
assert_eq!(ranged.until(100, ranged.covers(Some(2_500))), Some(130));
|
|
assert!(ranged.covers_event(Some(2_000 + 3_600)), "a day of slack for an event");
|
|
|
|
// Neither held nor undelete: nothing is kept
|
|
let none = Keeping::new(None, &[]);
|
|
assert!(!none.keeps_anything());
|
|
assert_eq!(none.until(100, false), None);
|
|
assert!(!is_held_until(100 + 30 * 365 * 86_400));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_as_json() {
|
|
let current = hold(accounts(&[2]), Some(100), None);
|
|
let json = serde_json::to_string(¤t).unwrap();
|
|
assert_eq!(serde_json::from_str::<Hold>(&json).unwrap(), current);
|
|
assert!(json.contains("\"scope\":{\"accounts\":[2]}"), "{json}");
|
|
}
|
|
}
|