Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db817dd507 | ||
|
|
b7e3a765ca | ||
|
|
7ba9ec9fa0 | ||
|
|
4c07779c16 | ||
|
|
e1e8a9aeb0 | ||
|
|
3978cf5785 | ||
|
|
815a642cc4 | ||
|
|
1d5f4a2cd3 | ||
|
|
68dd749291 | ||
|
|
b1bc5ed6e0 | ||
|
|
b074c73219 | ||
|
|
3046c418cd | ||
|
|
faeb1fed86 | ||
|
|
c1b5bf956c | ||
|
|
3217aae4e8 | ||
|
|
538ae107d7 | ||
|
|
39707cd2e8 | ||
|
|
8d3e99bc00 | ||
|
|
7b97efbb7f | ||
|
|
318783f444 | ||
|
|
5d2e35b2dc | ||
|
|
621ebdff74 | ||
|
|
355bd3a40e | ||
|
|
f7a63b9ed0 | ||
|
|
9f6761c9dd | ||
|
|
d4d127fa7d |
Generated
+2
@@ -4258,6 +4258,7 @@ dependencies = [
|
|||||||
"tungstenite 0.30.0",
|
"tungstenite 0.30.0",
|
||||||
"types",
|
"types",
|
||||||
"utils",
|
"utils",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8624,6 +8625,7 @@ dependencies = [
|
|||||||
"types",
|
"types",
|
||||||
"utils",
|
"utils",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
auth::{AccessToken, AuthRequest, permissions::DefaultPermissions},
|
auth::{AccessToken, AuthRequest, permissions::DefaultPermissions},
|
||||||
};
|
};
|
||||||
use directory::Credentials;
|
use directory::Credentials;
|
||||||
|
use inbuxa_features::hold::{self, Member};
|
||||||
use inbuxa_features::audit::{
|
use inbuxa_features::audit::{
|
||||||
Action, Actor, AuditLog, EntryId, Outcome, Record, Target, Via, diff, log, scope,
|
Action, Actor, AuditLog, EntryId, Outcome, Record, Target, Via, diff, log, scope,
|
||||||
};
|
};
|
||||||
@@ -448,7 +449,16 @@ impl Server {
|
|||||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||||
let settings = log::settings(self.store()).await?;
|
let settings = log::settings(self.store()).await?;
|
||||||
let cutoff = ms().saturating_sub(settings.keep_for_secs.saturating_mul(1000));
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +505,20 @@ impl RegistryWriteHook for SystemWrites {
|
|||||||
change: RegistryChange<'a>,
|
change: RegistryChange<'a>,
|
||||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
// LH-2: every change to an account, whoever makes it: one that
|
||||||
|
// leaves a held domain, group or tenant stays held by name
|
||||||
|
if change.object_type == ObjectType::Account
|
||||||
|
&& let (Some(before), Some(after)) = (change.before, change.after)
|
||||||
|
&& let (Some(before), Some(after)) = (
|
||||||
|
Member::of(change.id.document_id(), &before.inner),
|
||||||
|
Member::of(change.id.document_id(), &after.inner),
|
||||||
|
)
|
||||||
|
&& let Err(err) = hold::keep_moved(&self.data, &before, &after).await
|
||||||
|
{
|
||||||
|
trc::error!(err
|
||||||
|
.account_id(after.account)
|
||||||
|
.details("Failed to keep a moved account under its legal hold"));
|
||||||
|
}
|
||||||
let subsystem = match scope::current() {
|
let subsystem = match scope::current() {
|
||||||
Some(scope::Scope::Request | scope::Scope::Quiet) => return,
|
Some(scope::Scope::Request | scope::Scope::Quiet) => return,
|
||||||
Some(scope::Scope::System(subsystem)) => subsystem,
|
Some(scope::Scope::System(subsystem)) => subsystem,
|
||||||
|
|||||||
@@ -143,6 +143,29 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: AL-7: a delegate reaches the whole locked account,
|
||||||
|
// mail, calendars, contacts and files, even a kind it holds
|
||||||
|
// none of yet, so an empty one reads as empty rather than
|
||||||
|
// refused. What it may see or change there is still each
|
||||||
|
// container's grant.
|
||||||
|
for delegation in delegations.iter() {
|
||||||
|
let whole: Bitmap<Collection> = Bitmap::from_iter([
|
||||||
|
Collection::Mailbox,
|
||||||
|
Collection::Email,
|
||||||
|
Collection::Calendar,
|
||||||
|
Collection::CalendarEvent,
|
||||||
|
Collection::AddressBook,
|
||||||
|
Collection::ContactCard,
|
||||||
|
Collection::FileNode,
|
||||||
|
]);
|
||||||
|
match access_to.iter_mut().find(|a| a.account_id == delegation.account_id) {
|
||||||
|
Some(entry) => entry.collections.union(&whole),
|
||||||
|
None => access_to.push(AccessTo {
|
||||||
|
account_id: delegation.account_id,
|
||||||
|
collections: whole,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let now = now();
|
let now = now();
|
||||||
let mut credential_version = 0;
|
let mut credential_version = 0;
|
||||||
@@ -817,6 +840,13 @@ impl AccessToken {
|
|||||||
|
|
||||||
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
/// has one that hasn't ended.
|
/// has one that hasn't ended.
|
||||||
|
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
||||||
|
/// the locked account as its owner could, top-level folders included.
|
||||||
|
pub fn delegate_may_write(&self, account_id: u32) -> bool {
|
||||||
|
self.delegation(account_id)
|
||||||
|
.is_some_and(|d| d.access != inbuxa_features::lock::Access::Read)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
|
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
|
||||||
let now = now();
|
let now = now();
|
||||||
self.inner
|
self.inner
|
||||||
|
|||||||
@@ -104,6 +104,16 @@ impl Server {
|
|||||||
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
|
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
|
||||||
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
|
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
|
||||||
permissions.disabled.set(Permission::Impersonate as usize);
|
permissions.disabled.set(Permission::Impersonate as usize);
|
||||||
|
// inbuxa: LH-13: only server-level administrators see or place
|
||||||
|
// holds, and a hold may concern the tenant's own administrator
|
||||||
|
for permission in [
|
||||||
|
Permission::SysLegalHoldGet,
|
||||||
|
Permission::SysLegalHoldCreate,
|
||||||
|
Permission::SysLegalHoldUpdate,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
] {
|
||||||
|
permissions.disabled.set(permission as usize);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -254,6 +264,11 @@ impl Default for DefaultPermissions {
|
|||||||
default.tenant.push(permission);
|
default.tenant.push(permission);
|
||||||
}
|
}
|
||||||
Permission::Impersonate
|
Permission::Impersonate
|
||||||
|
// inbuxa: LH-13: holds are the server administrator's alone
|
||||||
|
| Permission::SysLegalHoldGet
|
||||||
|
| Permission::SysLegalHoldCreate
|
||||||
|
| Permission::SysLegalHoldUpdate
|
||||||
|
| Permission::SysLegalHoldExport
|
||||||
| Permission::UnlimitedRequests
|
| Permission::UnlimitedRequests
|
||||||
| Permission::UnlimitedUploads
|
| Permission::UnlimitedUploads
|
||||||
| Permission::LiveMetrics
|
| Permission::LiveMetrics
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ use utils::{
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
||||||
|
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod expr;
|
pub mod expr;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ use trc::AddContext;
|
|||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
|
|
||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), and the audit log
|
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
||||||
/// (audit-hold-lock spec, AU-9).
|
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13).
|
||||||
const ADMIN_GRANTS: &[Permission] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -40,6 +40,10 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockCreate,
|
Permission::SysAccountLockCreate,
|
||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
|
Permission::SysLegalHoldGet,
|
||||||
|
Permission::SysLegalHoldCreate,
|
||||||
|
Permission::SysLegalHoldUpdate,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the default tenant administrator roles: reading and exporting
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
|
|||||||
@@ -92,10 +92,8 @@ impl MailboxDestroy for Server {
|
|||||||
|
|
||||||
let mut deleted_ids = RoaringBitmap::new();
|
let mut deleted_ids = RoaringBitmap::new();
|
||||||
let mut thread_ids = RoaringBitmap::new();
|
let mut thread_ids = RoaringBitmap::new();
|
||||||
// inbuxa: UD-1, UD-6a: the retention in force now
|
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
|
||||||
let retention = inbuxa_features::undelete::settings::retention(self.registry())
|
let keeping = self.keeping(account_id).await?;
|
||||||
.await?
|
|
||||||
.items;
|
|
||||||
self.archives(
|
self.archives(
|
||||||
account_id,
|
account_id,
|
||||||
Collection::Email,
|
Collection::Email,
|
||||||
@@ -125,10 +123,10 @@ impl MailboxDestroy for Server {
|
|||||||
deleted_ids.insert(message_id);
|
deleted_ids.insert(message_id);
|
||||||
thread_ids.insert(prev_message_data.inner.thread_id.to_native());
|
thread_ids.insert(prev_message_data.inner.thread_id.to_native());
|
||||||
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
||||||
if let Some(retention) = retention {
|
if keeping.keeps_anything() {
|
||||||
inbuxa_features::undelete::email::note(
|
inbuxa_features::undelete::email::note(
|
||||||
&mut batch,
|
&mut batch,
|
||||||
retention,
|
&keeping,
|
||||||
account_id,
|
account_id,
|
||||||
message_id,
|
message_id,
|
||||||
prev_message_data.inner.size.to_native() as u64,
|
prev_message_data.inner.size.to_native() as u64,
|
||||||
|
|||||||
@@ -69,10 +69,8 @@ impl EmailDeletion for Server {
|
|||||||
batch
|
batch
|
||||||
.with_account_id(account_id)
|
.with_account_id(account_id)
|
||||||
.with_collection(Collection::Email);
|
.with_collection(Collection::Email);
|
||||||
// inbuxa: UD-1, UD-6a: the retention in force now
|
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
|
||||||
let retention = inbuxa_features::undelete::settings::retention(self.registry())
|
let keeping = self.keeping(account_id).await?;
|
||||||
.await?
|
|
||||||
.items;
|
|
||||||
self.archives(
|
self.archives(
|
||||||
account_id,
|
account_id,
|
||||||
Collection::Email,
|
Collection::Email,
|
||||||
@@ -90,10 +88,10 @@ impl EmailDeletion for Server {
|
|||||||
}
|
}
|
||||||
thread_ids.insert(metadata.inner.thread_id.to_native());
|
thread_ids.insert(metadata.inner.thread_id.to_native());
|
||||||
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
||||||
if let Some(retention) = retention {
|
if keeping.keeps_anything() {
|
||||||
inbuxa_features::undelete::email::note(
|
inbuxa_features::undelete::email::note(
|
||||||
batch,
|
batch,
|
||||||
retention,
|
&keeping,
|
||||||
account_id,
|
account_id,
|
||||||
document_id,
|
document_id,
|
||||||
metadata.inner.size.to_native() as u64,
|
metadata.inner.size.to_native() as u64,
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ impl SieveScriptDelete for Server {
|
|||||||
))
|
))
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
// inbuxa: UD-1: a deleted script is kept, when archiving is on
|
// inbuxa: UD-1, LH-4: a deleted script is kept, when archiving
|
||||||
if let Some(retention) =
|
// is on or a hold covers the account (whole: scripts have no date)
|
||||||
inbuxa_features::undelete::settings::retention(self.registry())
|
let keeping = self.keeping(account_id).await?;
|
||||||
.await?
|
let now = store::write::now();
|
||||||
.items
|
if let Some(until) = keeping.until(now, keeping.is_held()) {
|
||||||
{
|
let retention = until.saturating_sub(now);
|
||||||
let script = obj_
|
let script = obj_
|
||||||
.deserialize::<SieveScript>()
|
.deserialize::<SieveScript>()
|
||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
|
|||||||
@@ -0,0 +1,772 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
|
pub mod hold;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ use store::{
|
|||||||
write::{AnyClass, BatchBuilder, ValueClass},
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
};
|
};
|
||||||
use trc::AddContext;
|
use trc::AddContext;
|
||||||
|
use types::{
|
||||||
|
acl::{Acl, AclGrant},
|
||||||
|
collection::Collection,
|
||||||
|
};
|
||||||
|
use utils::map::bitmap::Bitmap;
|
||||||
|
|
||||||
/// Rung when a lock is written, so this node's expiry timer re-reads the
|
/// Rung when a lock is written, so this node's expiry timer re-reads the
|
||||||
/// `until` dates (AL-5): a delegation ends at its time, not at a sweep.
|
/// `until` dates (AL-5): a delegation ends at its time, not at a sweep.
|
||||||
@@ -53,11 +58,6 @@ pub fn ended_between(locks: &[Lock], after: u64, now: u64) -> impl Iterator<Item
|
|||||||
})
|
})
|
||||||
.map(|lock| lock.account_id)
|
.map(|lock| lock.account_id)
|
||||||
}
|
}
|
||||||
use types::{
|
|
||||||
acl::{Acl, AclGrant},
|
|
||||||
collection::Collection,
|
|
||||||
};
|
|
||||||
use utils::map::bitmap::Bitmap;
|
|
||||||
|
|
||||||
const FEATURE: u8 = b'K';
|
const FEATURE: u8 = b'K';
|
||||||
const KIND_LOCK: u8 = b'l';
|
const KIND_LOCK: u8 = b'l';
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ pub struct EmailNote {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub mailboxes: Vec<u32>,
|
pub mailboxes: Vec<u32>,
|
||||||
pub keywords: Vec<String>,
|
pub keywords: Vec<String>,
|
||||||
|
/// LH-3: the ranges of the holds on the account when it was deleted.
|
||||||
|
/// Its received date is only known when it's archived, which decides
|
||||||
|
/// whether a hold keeps it after all.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub held_ranges: Vec<(Option<u64>, Option<u64>)>,
|
||||||
|
/// The undelete deadline for when no range covers it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub otherwise_until: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What restore needs beyond the kept copy (UD-4, UD-8).
|
/// What restore needs beyond the kept copy (UD-4, UD-8).
|
||||||
@@ -462,8 +470,15 @@ mod tests {
|
|||||||
size: 3,
|
size: 3,
|
||||||
mailboxes: vec![1],
|
mailboxes: vec![1],
|
||||||
keywords: vec![],
|
keywords: vec![],
|
||||||
|
held_ranges: vec![(Some(10), None)],
|
||||||
|
otherwise_until: Some(20),
|
||||||
};
|
};
|
||||||
let bytes = Json(¬e).serialize().unwrap();
|
let bytes = Json(¬e).serialize().unwrap();
|
||||||
assert_eq!(Json::<EmailNote>::deserialize(&bytes).unwrap().0, note);
|
assert_eq!(Json::<EmailNote>::deserialize(&bytes).unwrap().0, note);
|
||||||
|
|
||||||
|
// A note written before legal holds still reads, as not held
|
||||||
|
let old = br#"{"archived_at":1,"archived_until":2,"size":3,"mailboxes":[1],"keywords":[]}"#;
|
||||||
|
let read = Json::<EmailNote>::deserialize(old).unwrap().0;
|
||||||
|
assert!(read.held_ranges.is_empty() && read.otherwise_until.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,12 @@
|
|||||||
//! is made if archiving is on, fixing the deadline then. When the data is
|
//! is made if archiving is on, fixing the deadline then. When the data is
|
||||||
//! finally removed, a noted message becomes an archived item.
|
//! finally removed, a noted message becomes an archived item.
|
||||||
|
|
||||||
use crate::undelete::{
|
use crate::{
|
||||||
|
hold::Keeping,
|
||||||
|
undelete::{
|
||||||
data::{self, EmailNote, Extra},
|
data::{self, EmailNote, Extra},
|
||||||
records,
|
records,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::structs::{ArchivedEmail, ArchivedItem},
|
schema::structs::{ArchivedEmail, ArchivedItem},
|
||||||
@@ -26,10 +29,12 @@ use store::{
|
|||||||
};
|
};
|
||||||
use types::{blob::BlobId, blob_hash::BlobHash};
|
use types::{blob::BlobId, blob_hash::BlobHash};
|
||||||
|
|
||||||
/// Notes a deleted message, when archiving is on (`retention` seconds).
|
/// Notes a deleted message, when anything keeps it: undelete, or a legal
|
||||||
|
/// hold on the account (LH-4). A held note keeps it until it's archived,
|
||||||
|
/// when its received date says whether the hold's range covers it.
|
||||||
pub fn note(
|
pub fn note(
|
||||||
batch: &mut BatchBuilder,
|
batch: &mut BatchBuilder,
|
||||||
retention: u64,
|
keeping: &Keeping,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
document_id: u32,
|
document_id: u32,
|
||||||
size: u64,
|
size: u64,
|
||||||
@@ -37,16 +42,23 @@ pub fn note(
|
|||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
let archived_at = now();
|
let archived_at = now();
|
||||||
|
// Held until the date is known; the undelete deadline otherwise
|
||||||
|
let otherwise_until = keeping.until(archived_at, false);
|
||||||
|
let Some(archived_until) = keeping.until(archived_at, keeping.is_held()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
data::note_email(
|
data::note_email(
|
||||||
batch,
|
batch,
|
||||||
account_id,
|
account_id,
|
||||||
document_id,
|
document_id,
|
||||||
&EmailNote {
|
&EmailNote {
|
||||||
archived_at,
|
archived_at,
|
||||||
archived_until: archived_at + retention,
|
archived_until,
|
||||||
size,
|
size,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
keywords,
|
keywords,
|
||||||
|
held_ranges: keeping.ranges.clone(),
|
||||||
|
otherwise_until: if keeping.is_held() { otherwise_until } else { None },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,9 +90,27 @@ pub async fn archive(
|
|||||||
document_id: u32,
|
document_id: u32,
|
||||||
summary: Summary<'_>,
|
summary: Summary<'_>,
|
||||||
) -> trc::Result<bool> {
|
) -> trc::Result<bool> {
|
||||||
let Some(note) = data::email_note(data, account_id, document_id).await? else {
|
let Some(mut note) = data::email_note(data, account_id, document_id).await? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
// LH-3: a held note's range decides now that the date is known; outside
|
||||||
|
// it, undelete's deadline, or nothing kept at all
|
||||||
|
if !note.held_ranges.is_empty() {
|
||||||
|
let keeping = Keeping {
|
||||||
|
retention: None,
|
||||||
|
ranges: std::mem::take(&mut note.held_ranges),
|
||||||
|
};
|
||||||
|
if !keeping.covers(Some(summary.received_at)) {
|
||||||
|
match note.otherwise_until {
|
||||||
|
Some(until) => note.archived_until = until,
|
||||||
|
None => {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
data::clear_email_note(&mut batch, account_id, document_id);
|
||||||
|
return data.write(batch.build_all()).await.map(|_| false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let item = ArchivedItem::Email(ArchivedEmail {
|
let item = ArchivedItem::Email(ArchivedEmail {
|
||||||
from: summary.from.unwrap_or_default().to_string(),
|
from: summary.from.unwrap_or_default().to_string(),
|
||||||
subject: summary.subject.unwrap_or_default().to_string(),
|
subject: summary.subject.unwrap_or_default().to_string(),
|
||||||
|
|||||||
@@ -97,6 +97,39 @@ pub async fn take(
|
|||||||
Ok(Some(note))
|
Ok(Some(note))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A note, left in place: for a held account it's cleared only once its item
|
||||||
|
/// is archived, so a failure leaves it for the retry (LH-5).
|
||||||
|
pub async fn peek(
|
||||||
|
data: &Store,
|
||||||
|
kind: Kind,
|
||||||
|
account_id: u32,
|
||||||
|
document_id: u32,
|
||||||
|
) -> trc::Result<Option<Note>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Note>>(ValueKey::from(note_class(kind, account_id, document_id)))
|
||||||
|
.await?
|
||||||
|
.map(|Json(note)| note))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a note once its item is archived or needn't be.
|
||||||
|
pub async fn clear(data: &Store, kind: Kind, account_id: u32, document_id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(note_class(kind, account_id, document_id));
|
||||||
|
data.write(batch.build_all()).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An event's start, for a hold's range (LH-3). None for a recurring event,
|
||||||
|
/// which may have an occurrence anywhere, so a hold keeps it whole.
|
||||||
|
pub fn event_start(note: &Note) -> Option<u64> {
|
||||||
|
let text = note.content.as_deref()?;
|
||||||
|
if property(text, "RRULE").is_some() || property(text, "RDATE").is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
property(text, "DTSTART")
|
||||||
|
.and_then(|v| ical_time(&v))
|
||||||
|
.map(|t| t.max(0) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
/// The value of the first line starting with `name` (as `NAME:` or
|
/// The value of the first line starting with `name` (as `NAME:` or
|
||||||
/// `NAME;params:`) in iCalendar or vCard text, unfolded.
|
/// `NAME;params:`) in iCalendar or vCard text, unfolded.
|
||||||
fn property(text: &str, name: &str) -> Option<String> {
|
fn property(text: &str, name: &str) -> Option<String> {
|
||||||
|
|||||||
@@ -89,6 +89,54 @@ pub async fn insert(
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves an archived item's deadline, and its kept copy's with it: frozen
|
||||||
|
/// by a hold (LH-6) or given a real one on release (LH-10). Returns the
|
||||||
|
/// item as it now is.
|
||||||
|
pub async fn set_deadline(
|
||||||
|
data: &Store,
|
||||||
|
registry: &RegistryStore,
|
||||||
|
id: Id,
|
||||||
|
item: &ArchivedItem,
|
||||||
|
until: u64,
|
||||||
|
) -> trc::Result<ArchivedItem> {
|
||||||
|
let account_id = item.account_id().document_id();
|
||||||
|
let blob_hash = item.blob_id().hash.clone();
|
||||||
|
let before = item.archived_until().timestamp() as u64;
|
||||||
|
let mut updated = item.clone();
|
||||||
|
updated.set_archived_until(registry::types::datetime::UTCDateTime::from_timestamp(until as i64));
|
||||||
|
|
||||||
|
// The new link first, so the kept copy is never unlinked in between
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch
|
||||||
|
.with_account_id(account_id)
|
||||||
|
.set(
|
||||||
|
BlobOp::Link {
|
||||||
|
hash: blob_hash.clone(),
|
||||||
|
to: BlobLink::Temporary { until },
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
if before != until {
|
||||||
|
batch.clear(BlobOp::Link {
|
||||||
|
hash: blob_hash,
|
||||||
|
to: BlobLink::Temporary { until: before },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data::log_change(&mut batch, account_id, registry.assign_id(), id, Change::Updated);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(item_class(id.id()), updated.to_pickled_vec());
|
||||||
|
registry
|
||||||
|
.store()
|
||||||
|
.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes an archived item and releases its kept copy: on restore (UD-9),
|
/// Removes an archived item and releases its kept copy: on restore (UD-9),
|
||||||
/// on destroy (UD-12) and past its deadline (UD-13).
|
/// on destroy (UD-12) and past its deadline (UD-13).
|
||||||
pub async fn remove(
|
pub async fn remove(
|
||||||
@@ -184,15 +232,38 @@ pub async fn get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every archived item on the server, account by account. Items are
|
||||||
|
/// indexed by account only, so the registry's query without a filter,
|
||||||
|
/// which reads its all-ids index, finds none of them.
|
||||||
|
pub async fn all(data: &Store, registry: &RegistryStore) -> trc::Result<Vec<Id>> {
|
||||||
|
let mut accounts = registry
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| id.document_id())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
// Deleted accounts still kept have archived items too
|
||||||
|
accounts.extend(data::kept_accounts(data).await?.into_iter().map(|(id, _)| id));
|
||||||
|
accounts.sort_unstable();
|
||||||
|
accounts.dedup();
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for account_id in accounts {
|
||||||
|
items.extend(
|
||||||
|
registry
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes every expired archived item on the server (UD-13), for the
|
/// Removes every expired archived item on the server (UD-13), for the
|
||||||
/// scheduled clean-up.
|
/// scheduled clean-up.
|
||||||
pub async fn remove_expired(data: &Store, registry: &RegistryStore) -> trc::Result<usize> {
|
pub async fn remove_expired(data: &Store, registry: &RegistryStore) -> trc::Result<usize> {
|
||||||
let mut removed = 0;
|
let mut removed = 0;
|
||||||
for id in registry
|
for id in all(data, registry).await? {
|
||||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem))
|
|
||||||
.await
|
|
||||||
.caused_by(trc::location!())?
|
|
||||||
{
|
|
||||||
if let Some(item) = registry.object::<ArchivedItem>(id).await?
|
if let Some(item) = registry.object::<ArchivedItem>(id).await?
|
||||||
&& is_expired(&item)
|
&& is_expired(&item)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -243,10 +243,8 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
|
|
||||||
let mut fully_deleted = RoaringBitmap::new();
|
let mut fully_deleted = RoaringBitmap::new();
|
||||||
let mut thread_ids = RoaringBitmap::new();
|
let mut thread_ids = RoaringBitmap::new();
|
||||||
// inbuxa: UD-1, UD-6a: the retention in force now
|
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
|
||||||
let retention = inbuxa_features::undelete::settings::retention(self.server.registry())
|
let keeping = self.server.keeping(account_id).await?;
|
||||||
.await?
|
|
||||||
.items;
|
|
||||||
self.server
|
self.server
|
||||||
.archives(
|
.archives(
|
||||||
account_id,
|
account_id,
|
||||||
@@ -270,10 +268,10 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
fully_deleted.insert(document_id);
|
fully_deleted.insert(document_id);
|
||||||
thread_ids.insert(metadata.inner.thread_id.to_native());
|
thread_ids.insert(metadata.inner.thread_id.to_native());
|
||||||
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
||||||
if let Some(retention) = retention {
|
if keeping.keeps_anything() {
|
||||||
inbuxa_features::undelete::email::note(
|
inbuxa_features::undelete::email::note(
|
||||||
batch,
|
batch,
|
||||||
retention,
|
&keeping,
|
||||||
account_id,
|
account_id,
|
||||||
document_id,
|
document_id,
|
||||||
metadata.inner.size.to_native() as u64,
|
metadata.inner.size.to_native() as u64,
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:HoldExport/get` and `/set` under `urn:inbuxa:jmap`: collecting
|
||||||
|
//! what a legal hold keeps as a ZIP (audit-hold-lock spec, LH-12). Creating
|
||||||
|
//! one starts it; it runs in the background, and `get` says when it's ready
|
||||||
|
//! and which blob to download. The set call's `reason` says why (AU-12).
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
object::{AnyId, JmapObject, JmapObjectId},
|
||||||
|
request::deserialize::DeserializeArguments,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HoldExport;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum HoldExportProperty {
|
||||||
|
Id,
|
||||||
|
HoldId,
|
||||||
|
AccountIds,
|
||||||
|
Reason,
|
||||||
|
Status,
|
||||||
|
CreatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
FinishedAt,
|
||||||
|
BlobId,
|
||||||
|
Size,
|
||||||
|
Items,
|
||||||
|
Sha256,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum HoldExportValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for HoldExportProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
match parent {
|
||||||
|
None => HoldExportProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
HoldExportProperty::Id => "id",
|
||||||
|
HoldExportProperty::HoldId => "holdId",
|
||||||
|
HoldExportProperty::AccountIds => "accountIds",
|
||||||
|
HoldExportProperty::Reason => "reason",
|
||||||
|
HoldExportProperty::Status => "status",
|
||||||
|
HoldExportProperty::CreatedAt => "createdAt",
|
||||||
|
HoldExportProperty::CreatedBy => "createdBy",
|
||||||
|
HoldExportProperty::FinishedAt => "finishedAt",
|
||||||
|
HoldExportProperty::BlobId => "blobId",
|
||||||
|
HoldExportProperty::Size => "size",
|
||||||
|
HoldExportProperty::Items => "items",
|
||||||
|
HoldExportProperty::Sha256 => "sha256",
|
||||||
|
HoldExportProperty::Error => "error",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HoldExportProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => HoldExportProperty::Id,
|
||||||
|
b"holdId" => HoldExportProperty::HoldId,
|
||||||
|
b"accountIds" => HoldExportProperty::AccountIds,
|
||||||
|
b"reason" => HoldExportProperty::Reason,
|
||||||
|
b"status" => HoldExportProperty::Status,
|
||||||
|
b"createdAt" => HoldExportProperty::CreatedAt,
|
||||||
|
b"createdBy" => HoldExportProperty::CreatedBy,
|
||||||
|
b"finishedAt" => HoldExportProperty::FinishedAt,
|
||||||
|
b"blobId" => HoldExportProperty::BlobId,
|
||||||
|
b"size" => HoldExportProperty::Size,
|
||||||
|
b"items" => HoldExportProperty::Items,
|
||||||
|
b"sha256" => HoldExportProperty::Sha256,
|
||||||
|
b"error" => HoldExportProperty::Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for HoldExportProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
HoldExportProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for HoldExportValue {
|
||||||
|
type Property = HoldExportProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(HoldExportProperty::Id) => Id::from_str(value).ok().map(HoldExportValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
HoldExportValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own arguments: why (AU-12).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HoldExportSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for HoldExportSetArguments {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
if key == "reason" {
|
||||||
|
self.reason = map.next_value()?;
|
||||||
|
} else {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for HoldExport {
|
||||||
|
type Property = HoldExportProperty;
|
||||||
|
|
||||||
|
type Element = HoldExportValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = HoldExportSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = HoldExportProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for HoldExportValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
HoldExportValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for HoldExportValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
HoldExportValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
HoldExportValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = HoldExportValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for HoldExportProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:LegalHold/get` and `/set` under `urn:inbuxa:jmap`: legal holds
|
||||||
|
//! (audit-hold-lock spec, LH-1 to LH-14). Creating one places the hold;
|
||||||
|
//! updating renames it, widens its range or scope, or releases it with
|
||||||
|
//! `released: true`. There is no destroy: a released hold stays listed. The
|
||||||
|
//! set call's `reason` argument says why, for the audit log (AU-12);
|
||||||
|
//! creating takes it as a property too.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
object::{AnyId, JmapObject, JmapObjectId},
|
||||||
|
request::deserialize::DeserializeArguments,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LegalHold;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum LegalHoldProperty {
|
||||||
|
Id,
|
||||||
|
/// The case name.
|
||||||
|
Name,
|
||||||
|
/// A matter or ticket number.
|
||||||
|
Reference,
|
||||||
|
Description,
|
||||||
|
/// `{server, accounts, groups, domains, tenants}`.
|
||||||
|
Scope,
|
||||||
|
/// The range's start, a UTC date, or null.
|
||||||
|
From,
|
||||||
|
/// The range's end, a UTC date, or null.
|
||||||
|
To,
|
||||||
|
/// Why it was placed (create only; later reasons are the audit log's).
|
||||||
|
Reason,
|
||||||
|
PlacedAt,
|
||||||
|
PlacedBy,
|
||||||
|
/// Set to true to release it.
|
||||||
|
Released,
|
||||||
|
ReleasedAt,
|
||||||
|
ReleasedBy,
|
||||||
|
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)]
|
||||||
|
pub enum LegalHoldValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for LegalHoldProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside the scope stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => LegalHoldProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
LegalHoldProperty::Id => "id",
|
||||||
|
LegalHoldProperty::Name => "name",
|
||||||
|
LegalHoldProperty::Reference => "reference",
|
||||||
|
LegalHoldProperty::Description => "description",
|
||||||
|
LegalHoldProperty::Scope => "scope",
|
||||||
|
LegalHoldProperty::From => "from",
|
||||||
|
LegalHoldProperty::To => "to",
|
||||||
|
LegalHoldProperty::Reason => "reason",
|
||||||
|
LegalHoldProperty::PlacedAt => "placedAt",
|
||||||
|
LegalHoldProperty::PlacedBy => "placedBy",
|
||||||
|
LegalHoldProperty::Released => "released",
|
||||||
|
LegalHoldProperty::ReleasedAt => "releasedAt",
|
||||||
|
LegalHoldProperty::ReleasedBy => "releasedBy",
|
||||||
|
LegalHoldProperty::ReleaseReason => "releaseReason",
|
||||||
|
LegalHoldProperty::AccountsCovered => "accountsCovered",
|
||||||
|
LegalHoldProperty::ItemsHeld => "itemsHeld",
|
||||||
|
LegalHoldProperty::SizeHeld => "sizeHeld",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegalHoldProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => LegalHoldProperty::Id,
|
||||||
|
b"name" => LegalHoldProperty::Name,
|
||||||
|
b"reference" => LegalHoldProperty::Reference,
|
||||||
|
b"description" => LegalHoldProperty::Description,
|
||||||
|
b"scope" => LegalHoldProperty::Scope,
|
||||||
|
b"from" => LegalHoldProperty::From,
|
||||||
|
b"to" => LegalHoldProperty::To,
|
||||||
|
b"reason" => LegalHoldProperty::Reason,
|
||||||
|
b"placedAt" => LegalHoldProperty::PlacedAt,
|
||||||
|
b"placedBy" => LegalHoldProperty::PlacedBy,
|
||||||
|
b"released" => LegalHoldProperty::Released,
|
||||||
|
b"releasedAt" => LegalHoldProperty::ReleasedAt,
|
||||||
|
b"releasedBy" => LegalHoldProperty::ReleasedBy,
|
||||||
|
b"releaseReason" => LegalHoldProperty::ReleaseReason,
|
||||||
|
b"accountsCovered" => LegalHoldProperty::AccountsCovered,
|
||||||
|
b"itemsHeld" => LegalHoldProperty::ItemsHeld,
|
||||||
|
b"sizeHeld" => LegalHoldProperty::SizeHeld,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for LegalHoldProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
LegalHoldProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for LegalHoldValue {
|
||||||
|
type Property = LegalHoldProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(LegalHoldProperty::Id) => Id::from_str(value).ok().map(LegalHoldValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
LegalHoldValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The get call's own argument: only the active holds covering an account,
|
||||||
|
/// through any route (LH-2), for the console's Held badge (LH-14).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LegalHoldGetArguments {
|
||||||
|
pub covering_account: Option<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for LegalHoldGetArguments {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
if key == "coveringAccount" {
|
||||||
|
self.covering_account = map.next_value()?;
|
||||||
|
} else {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own arguments: why (AU-12).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LegalHoldSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for LegalHoldSetArguments {
|
||||||
|
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
if key == "reason" {
|
||||||
|
self.reason = map.next_value()?;
|
||||||
|
} else {
|
||||||
|
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for LegalHold {
|
||||||
|
type Property = LegalHoldProperty;
|
||||||
|
|
||||||
|
type Element = LegalHoldValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = LegalHoldGetArguments;
|
||||||
|
|
||||||
|
type SetArguments<'de> = LegalHoldSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = LegalHoldProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for LegalHoldValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
LegalHoldValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for LegalHoldValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
LegalHoldValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
LegalHoldValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = LegalHoldValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for LegalHoldProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ pub mod fastmail_masked_email; // inbuxa: masked email
|
|||||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||||
|
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||||
|
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::AccountLock(response) => {
|
GetResponseMethod::AccountLock(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::LegalHold(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::HoldExport(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::ProtocolPolicy(response) => {
|
GetResponseMethod::ProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
@@ -111,6 +113,12 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::AccountLock(request) => {
|
SetRequestMethod::AccountLock(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::LegalHold(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::HoldExport(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::ProtocolPolicy(request) => {
|
SetRequestMethod::ProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ pub enum MethodObject {
|
|||||||
AuditVerification,
|
AuditVerification,
|
||||||
// inbuxa: account lock with delegation
|
// inbuxa: account lock with delegation
|
||||||
AccountLock,
|
AccountLock,
|
||||||
|
// inbuxa: legal hold
|
||||||
|
LegalHold,
|
||||||
|
HoldExport,
|
||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,9 @@ impl MethodObject {
|
|||||||
| MethodObject::AuditSettings
|
| MethodObject::AuditSettings
|
||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock => Capability::Inbuxa,
|
| MethodObject::AccountLock
|
||||||
|
| MethodObject::LegalHold
|
||||||
|
| MethodObject::HoldExport => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
@@ -279,6 +284,10 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
|
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
|
||||||
(MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get",
|
(MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get",
|
||||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||||
|
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||||
|
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||||
|
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||||
|
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||||
"inbuxa:AuditVerification/set"
|
"inbuxa:AuditVerification/set"
|
||||||
}
|
}
|
||||||
@@ -423,6 +432,10 @@ impl MethodName {
|
|||||||
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
|
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
|
||||||
"inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get),
|
"inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get),
|
||||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||||
|
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||||
|
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||||
|
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||||
|
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
@@ -487,6 +500,8 @@ impl Display for MethodObject {
|
|||||||
MethodObject::AuditExport => "inbuxa:AuditExport",
|
MethodObject::AuditExport => "inbuxa:AuditExport",
|
||||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||||
|
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||||
|
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ pub enum GetRequestMethod {
|
|||||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
|
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
@@ -151,6 +153,8 @@ pub enum SetRequestMethod<'x> {
|
|||||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
|
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
|
|||||||
@@ -566,6 +566,36 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// inbuxa: legal hold exports
|
||||||
|
(MethodFunction::Get, MethodObject::HoldExport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HoldExport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::HoldExport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HoldExport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// inbuxa: legal hold
|
||||||
|
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::LegalHold) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LegalHold(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
// inbuxa: the audit log
|
// inbuxa: the audit log
|
||||||
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ pub enum GetResponseMethod {
|
|||||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||||
|
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||||
|
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
@@ -138,6 +140,8 @@ pub enum SetResponseMethod {
|
|||||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
|
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
@@ -765,3 +769,29 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
|
|||||||
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
|
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: legal hold
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: legal hold exports
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::HoldExport(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ base64 = "0.23"
|
|||||||
p256 = { version = "0.13", features = ["ecdh"] }
|
p256 = { version = "0.13", features = ["ecdh"] }
|
||||||
sha1 = "0.11"
|
sha1 = "0.11"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
zip = "8.6" # inbuxa: legal hold exports (LH-12)
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
||||||
tokio-tungstenite = "0.30"
|
tokio-tungstenite = "0.30"
|
||||||
tungstenite = "0.30"
|
tungstenite = "0.30"
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
}
|
}
|
||||||
// inbuxa: account lock (AL-12)
|
// inbuxa: account lock (AL-12)
|
||||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||||
|
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||||
|
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||||
// inbuxa: legacy protocols off. It takes listeners away and
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
@@ -222,6 +224,23 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
),
|
),
|
||||||
|
// inbuxa: legal hold (LH-13); holds are never destroyed,
|
||||||
|
// and the handler refuses a destroy outright
|
||||||
|
SetRequestMethod::LegalHold(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysLegalHoldCreate,
|
||||||
|
Permission::SysLegalHoldUpdate,
|
||||||
|
Permission::SysLegalHoldUpdate,
|
||||||
|
),
|
||||||
|
// inbuxa: LH-12, exporting held data
|
||||||
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
Permission::SysLegalHoldExport,
|
||||||
|
),
|
||||||
SetRequestMethod::AuditVerification(s) => validate_set(
|
SetRequestMethod::AuditVerification(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -369,6 +388,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
|
| MethodObject::LegalHold
|
||||||
|
| MethodObject::HoldExport
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
|
|||||||
@@ -273,6 +273,12 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::AccountLock(set_response) => {
|
SetResponseMethod::AccountLock(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::LegalHold(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::HoldExport(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::Explanation(set_response) => {
|
SetResponseMethod::Explanation(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -446,6 +452,16 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: legal hold (LH-1)
|
||||||
|
// inbuxa: legal hold exports (LH-12)
|
||||||
|
GetRequestMethod::HoldExport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::hold_export_api::get(self, *req).await?.into()
|
||||||
|
}
|
||||||
|
GetRequestMethod::LegalHold(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||||
|
}
|
||||||
// inbuxa: the audit log (AU-9)
|
// inbuxa: the audit log (AU-9)
|
||||||
GetRequestMethod::AuditEvent(mut req) => {
|
GetRequestMethod::AuditEvent(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -797,6 +813,62 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: legal hold (LH-1), each change recorded with its
|
||||||
|
// reason (AU-12)
|
||||||
|
// inbuxa: legal hold exports, recorded with their reason
|
||||||
|
// (AU-1.9, AU-12)
|
||||||
|
SetRequestMethod::HoldExport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone().or_else(|| {
|
||||||
|
req.create.as_ref().and_then(|create| {
|
||||||
|
create.values().find_map(|value| {
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.ok()?
|
||||||
|
.get("reason")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::hold_export_api::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
SetRequestMethod::LegalHold(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
let reason = req.arguments.reason.clone().or_else(|| {
|
||||||
|
req.create.as_ref().and_then(|create| {
|
||||||
|
create.values().find_map(|value| {
|
||||||
|
serde_json::to_value(value)
|
||||||
|
.ok()?
|
||||||
|
.get("reason")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::legal_hold::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AuditExport(mut req) => {
|
SetRequestMethod::AuditExport(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::AuditExport
|
| MethodObject::AuditExport
|
||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
|
| MethodObject::LegalHold
|
||||||
|
| MethodObject::HoldExport
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
|
|||||||
@@ -226,9 +226,14 @@ impl FileNodeCopy for Server {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) =
|
// inbuxa: AL-7: a writing delegate may add at the top
|
||||||
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
|
if let Err(err) = validate_file_node_hierarchy(
|
||||||
{
|
None,
|
||||||
|
&file_node,
|
||||||
|
is_shared && !access_token.delegate_may_write(account_id),
|
||||||
|
&cache,
|
||||||
|
&created_folders,
|
||||||
|
) {
|
||||||
response.not_created.append(id, err);
|
response.not_created.append(id, err);
|
||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
@@ -362,7 +367,7 @@ impl FileNodeCopy for Server {
|
|||||||
);
|
);
|
||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
} else if is_shared {
|
} else if is_shared && !access_token.delegate_may_write(account_id) {
|
||||||
response.not_created.append(
|
response.not_created.append(
|
||||||
id,
|
id,
|
||||||
SetError::forbidden()
|
SetError::forbidden()
|
||||||
|
|||||||
@@ -149,9 +149,15 @@ impl FileNodeSet for Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Validate hierarchy
|
// Validate hierarchy
|
||||||
if let Err(err) =
|
// inbuxa: AL-7: a writing delegate may add at the top of a
|
||||||
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
|
// locked account, which may hold no folders at all
|
||||||
{
|
if let Err(err) = validate_file_node_hierarchy(
|
||||||
|
None,
|
||||||
|
&file_node,
|
||||||
|
is_shared && !access_token.delegate_may_write(account_id),
|
||||||
|
&cache,
|
||||||
|
&created_folders,
|
||||||
|
) {
|
||||||
response.not_created.append(id, err);
|
response.not_created.append(id, err);
|
||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ async fn before<T: JmapObject>(
|
|||||||
|
|
||||||
for (client_id, value) in request.create.iter().flat_map(|c| c.iter()) {
|
for (client_id, value) in request.create.iter().flat_map(|c| c.iter()) {
|
||||||
let after = serde_json::to_value(value).unwrap_or_default();
|
let after = serde_json::to_value(value).unwrap_or_default();
|
||||||
let described = diff::describe(&after);
|
let mut described = diff::describe(&after);
|
||||||
|
described.name = full_name(server, object, &after, described.name).await;
|
||||||
let changes = after
|
let changes = after
|
||||||
.as_object()
|
.as_object()
|
||||||
.map(|patch| diff::patch(object, None, patch))
|
.map(|patch| diff::patch(object, None, patch))
|
||||||
@@ -192,7 +193,10 @@ async fn before<T: JmapObject>(
|
|||||||
None => fork_current(server, object, id).await,
|
None => fork_current(server, object, id).await,
|
||||||
};
|
};
|
||||||
let patch = serde_json::to_value(value).unwrap_or_default();
|
let patch = serde_json::to_value(value).unwrap_or_default();
|
||||||
let described = before.as_ref().map(diff::describe).unwrap_or_default();
|
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||||
|
if let Some(before) = &before {
|
||||||
|
described.name = full_name(server, object, before, described.name).await;
|
||||||
|
}
|
||||||
let changes = patch
|
let changes = patch
|
||||||
.as_object()
|
.as_object()
|
||||||
.map(|patch| diff::patch(object, before.as_ref(), patch))
|
.map(|patch| diff::patch(object, before.as_ref(), patch))
|
||||||
@@ -214,7 +218,10 @@ async fn before<T: JmapObject>(
|
|||||||
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
||||||
for id in destroy {
|
for id in destroy {
|
||||||
let before = stored(server, registry, id).await;
|
let before = stored(server, registry, id).await;
|
||||||
let described = before.as_ref().map(diff::describe).unwrap_or_default();
|
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||||
|
if let Some(before) = &before {
|
||||||
|
described.name = full_name(server, object, before, described.name).await;
|
||||||
|
}
|
||||||
records.push((
|
records.push((
|
||||||
Item::Destroy(id.clone()),
|
Item::Destroy(id.clone()),
|
||||||
Action::Destroy,
|
Action::Destroy,
|
||||||
@@ -345,6 +352,29 @@ fn id_text(id: &MaybeInvalid<Id>) -> String {
|
|||||||
/// The fork's own settings as they are now, as JSON, so their changes are
|
/// The fork's own settings as they are now, as JSON, so their changes are
|
||||||
/// recorded with what they replaced. Their stored names are the JMAP
|
/// recorded with what they replaced. Their stored names are the JMAP
|
||||||
/// property names.
|
/// property names.
|
||||||
|
/// An account's or a mailing list's name is only its local part, and two
|
||||||
|
/// domains' "leslie" would read alike: records name it by its full address.
|
||||||
|
async fn full_name(server: &Server, object: &str, value: &Value, name: Option<String>) -> Option<String> {
|
||||||
|
let name = name?;
|
||||||
|
if !matches!(object, "x:Account" | "x:MailingList") || name.contains('@') {
|
||||||
|
return Some(name);
|
||||||
|
}
|
||||||
|
let domain = value
|
||||||
|
.get("domainId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|id| <Id as std::str::FromStr>::from_str(id).ok());
|
||||||
|
match domain {
|
||||||
|
Some(domain) => match server.domain_by_id(domain.document_id()).await {
|
||||||
|
Ok(Some(domain)) => match domain.names.first() {
|
||||||
|
Some(domain) => Some(format!("{name}@{domain}")),
|
||||||
|
None => Some(name),
|
||||||
|
},
|
||||||
|
_ => Some(name),
|
||||||
|
},
|
||||||
|
None => Some(name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
||||||
use inbuxa_features::{ai::limits, audit::log, security};
|
use inbuxa_features::{ai::limits, audit::log, security};
|
||||||
let data = server.store();
|
let data = server.store();
|
||||||
@@ -361,6 +391,34 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
.and_then(|policy| serde_json::to_value(policy).ok()),
|
||||||
|
// LH-1: a hold as the API shows it, so a change reads before/after
|
||||||
|
"inbuxa:LegalHold" => match id {
|
||||||
|
MaybeInvalid::Value(id) => {
|
||||||
|
let hold = inbuxa_features::hold::get(data, u32::try_from(id.id()).ok()?)
|
||||||
|
.await
|
||||||
|
.ok()??;
|
||||||
|
let ids = |list: &[u32]| list.iter().map(|id| Id::from(*id).to_string()).collect::<Vec<_>>();
|
||||||
|
let date = |at: Option<u64>| {
|
||||||
|
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at as i64).to_string())
|
||||||
|
};
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"name": hold.name,
|
||||||
|
"reference": hold.reference,
|
||||||
|
"description": hold.description,
|
||||||
|
"scope": {
|
||||||
|
"server": hold.scope.server,
|
||||||
|
"accounts": ids(&hold.scope.accounts),
|
||||||
|
"groups": ids(&hold.scope.groups),
|
||||||
|
"domains": ids(&hold.scope.domains),
|
||||||
|
"tenants": ids(&hold.scope.tenants),
|
||||||
|
},
|
||||||
|
"from": date(hold.from),
|
||||||
|
"to": date(hold.to),
|
||||||
|
"released": !hold.is_active(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
MaybeInvalid::Invalid(_) => None,
|
||||||
|
},
|
||||||
"inbuxa:TenantProtocolPolicy" => match id {
|
"inbuxa:TenantProtocolPolicy" => match id {
|
||||||
MaybeInvalid::Value(id) => {
|
MaybeInvalid::Value(id) => {
|
||||||
security::tenant_protocol_policy::get(data, id.document_id())
|
security::tenant_protocol_policy::get(data, id.document_id())
|
||||||
|
|||||||
@@ -124,13 +124,29 @@ pub async fn reserved(
|
|||||||
/// of upstream's immediate destruction. Returns the other accounts whose
|
/// of upstream's immediate destruction. Returns the other accounts whose
|
||||||
/// access changed, or `None` when nothing is kept.
|
/// access changed, or `None` when nothing is kept.
|
||||||
pub async fn keep(server: &Server, id: Id, account: &Account) -> trc::Result<Option<Vec<u32>>> {
|
pub async fn keep(server: &Server, id: Id, account: &Account) -> trc::Result<Option<Vec<u32>>> {
|
||||||
let Some(period) = retention(server.registry()).await?.accounts else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let account_id = id.document_id();
|
let account_id = id.document_id();
|
||||||
let deleted_at = now();
|
|
||||||
let kept_until = deleted_at + period;
|
|
||||||
let inner = ObjectInner::Account(account.clone());
|
let inner = ObjectInner::Account(account.clone());
|
||||||
|
// inbuxa: LH-8: a held account's data stays, with no expiry, whether or
|
||||||
|
// not undelete keeps accounts; its holds name it from now on
|
||||||
|
let member = inbuxa_features::hold::Member::of(account_id, &inner);
|
||||||
|
let held = match &member {
|
||||||
|
Some(member) => {
|
||||||
|
!inbuxa_features::hold::covering(server.store(), member)
|
||||||
|
.await?
|
||||||
|
.is_empty()
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
let period = retention(server.registry()).await?.accounts;
|
||||||
|
let deleted_at = now();
|
||||||
|
let kept_until = match (held, period) {
|
||||||
|
(true, _) => inbuxa_features::hold::HELD_UNTIL,
|
||||||
|
(false, Some(period)) => deleted_at + period,
|
||||||
|
(false, None) => return Ok(None),
|
||||||
|
};
|
||||||
|
if held && let Some(member) = &member {
|
||||||
|
inbuxa_features::hold::pin_account(server.store(), member).await?;
|
||||||
|
}
|
||||||
let addresses = addresses_of(server, &inner)
|
let addresses = addresses_of(server, &inner)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -324,6 +340,18 @@ pub async fn set(
|
|||||||
|
|
||||||
for id in will_destroy {
|
for id in will_destroy {
|
||||||
match data::kept_account(data, id.document_id()).await? {
|
match data::kept_account(data, id.document_id()).await? {
|
||||||
|
// inbuxa: LH-8: a held account's data can't be destroyed
|
||||||
|
Some(kept)
|
||||||
|
if may_reach(access_token, &kept, Permission::SysAccountDestroy)
|
||||||
|
&& (inbuxa_features::hold::is_held_until(kept.kept_until)
|
||||||
|
|| server.is_kept_held(id.document_id(), &kept).await?) =>
|
||||||
|
{
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden()
|
||||||
|
.with_description("A legal hold applies to this account, so its data stays."),
|
||||||
|
);
|
||||||
|
}
|
||||||
Some(kept) if may_reach(access_token, &kept, Permission::SysAccountDestroy) => {
|
Some(kept) if may_reach(access_token, &kept, Permission::SysAccountDestroy) => {
|
||||||
destroy_now(server, id, &kept).await?;
|
destroy_now(server, id, &kept).await?;
|
||||||
response.destroyed.push(id);
|
response.destroyed.push(id);
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Collecting what a legal hold keeps, as a ZIP (audit-hold-lock spec,
|
||||||
|
//! LH-12). For each account the hold covers (or those asked for): its mail,
|
||||||
|
//! calendars, contacts and files, and the deleted items the hold keeps, each
|
||||||
|
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
|
||||||
|
//! it. The hold's date range applies as it does to what's kept (LH-3).
|
||||||
|
|
||||||
|
use common::{Server, hold::kept_member};
|
||||||
|
use email::{
|
||||||
|
cache::MessageCacheFetch,
|
||||||
|
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
|
||||||
|
};
|
||||||
|
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard, file::FileNode};
|
||||||
|
use inbuxa_features::{
|
||||||
|
hold::{Hold, Keeping, is_held_until},
|
||||||
|
undelete::records,
|
||||||
|
};
|
||||||
|
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::{Cursor, Write};
|
||||||
|
use store::{
|
||||||
|
ValueKey,
|
||||||
|
registry::RegistryQuery,
|
||||||
|
write::{AlignedBytes, Archive},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
use types::{
|
||||||
|
collection::{Collection, SyncCollection},
|
||||||
|
field::EmailField,
|
||||||
|
id::Id,
|
||||||
|
};
|
||||||
|
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||||
|
|
||||||
|
/// The largest ZIP built in memory. A bigger collection is refused with a
|
||||||
|
/// clear error rather than taking the node down; export fewer accounts.
|
||||||
|
pub const MAX_EXPORT: u64 = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// One line of `manifest.csv`.
|
||||||
|
struct Entry {
|
||||||
|
path: String,
|
||||||
|
account: String,
|
||||||
|
kind: &'static str,
|
||||||
|
folder: String,
|
||||||
|
date: Option<i64>,
|
||||||
|
archived: bool,
|
||||||
|
size: usize,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csv(field: &str) -> String {
|
||||||
|
if field.contains([',', '"', '\n', '\r']) {
|
||||||
|
format!("\"{}\"", field.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
field.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path segment that's safe in a ZIP: no separators, no leading dots.
|
||||||
|
fn segment(name: &str) -> String {
|
||||||
|
let cleaned: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
|
||||||
|
.collect();
|
||||||
|
let trimmed = cleaned.trim_start_matches('.').trim();
|
||||||
|
if trimmed.is_empty() { "_".into() } else { trimmed.chars().take(120).collect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_text(at: Option<i64>) -> String {
|
||||||
|
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Builder {
|
||||||
|
zip: ZipWriter<Cursor<Vec<u8>>>,
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Builder {
|
||||||
|
fn new() -> Self {
|
||||||
|
Builder {
|
||||||
|
zip: ZipWriter::new(Cursor::new(Vec::new())),
|
||||||
|
entries: Vec::new(),
|
||||||
|
written: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn add(
|
||||||
|
&mut self,
|
||||||
|
path: String,
|
||||||
|
bytes: &[u8],
|
||||||
|
account: &str,
|
||||||
|
kind: &'static str,
|
||||||
|
folder: &str,
|
||||||
|
date: Option<i64>,
|
||||||
|
archived: bool,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
self.written += bytes.len() as u64;
|
||||||
|
if self.written > MAX_EXPORT {
|
||||||
|
return Err(trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("The collection is larger than one export can hold (2 GB). Export fewer accounts at a time."));
|
||||||
|
}
|
||||||
|
// Unique within the ZIP, however names collide
|
||||||
|
let mut name = path.clone();
|
||||||
|
let mut n = 1;
|
||||||
|
while self.entries.iter().any(|e| e.path == name) {
|
||||||
|
n += 1;
|
||||||
|
name = match path.rsplit_once('.') {
|
||||||
|
Some((stem, ext)) if !stem.ends_with('/') => format!("{stem} ({n}).{ext}"),
|
||||||
|
_ => format!("{path} ({n})"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||||
|
self.zip
|
||||||
|
.start_file(name.as_str(), options)
|
||||||
|
.and_then(|_| self.zip.write_all(bytes).map_err(Into::into))
|
||||||
|
.map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to write the export")
|
||||||
|
.reason(err)
|
||||||
|
})?;
|
||||||
|
self.entries.push(Entry {
|
||||||
|
path: name,
|
||||||
|
account: account.to_string(),
|
||||||
|
kind,
|
||||||
|
folder: folder.to_string(),
|
||||||
|
date,
|
||||||
|
archived,
|
||||||
|
size: bytes.len(),
|
||||||
|
sha256: hex(&Sha256::digest(bytes)),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the ZIP with its manifest and the manifest's hash. Returns the
|
||||||
|
/// bytes and how many items went in.
|
||||||
|
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
|
||||||
|
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
|
||||||
|
for e in &self.entries {
|
||||||
|
manifest.push_str(&format!(
|
||||||
|
"{},{},{},{},{},{},{},{}\n",
|
||||||
|
csv(&e.path),
|
||||||
|
csv(&e.account),
|
||||||
|
e.kind,
|
||||||
|
csv(&e.folder),
|
||||||
|
date_text(e.date),
|
||||||
|
e.archived,
|
||||||
|
e.size,
|
||||||
|
e.sha256
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
||||||
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||||
|
let fail = |err: zip::result::ZipError| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to write the export")
|
||||||
|
.reason(err)
|
||||||
|
};
|
||||||
|
self.zip.start_file("manifest.csv", options).map_err(fail)?;
|
||||||
|
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||||
|
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
|
||||||
|
self.zip
|
||||||
|
.write_all(format!("{manifest_hash} manifest.csv\n").as_bytes())
|
||||||
|
.map_err(|e| fail(e.into()))?;
|
||||||
|
let items = self.entries.len();
|
||||||
|
let bytes = self.zip.finish().map_err(fail)?.into_inner();
|
||||||
|
Ok((bytes, items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accounts to collect: those asked for that the hold covers, or every
|
||||||
|
/// account it covers, deleted ones it keeps included.
|
||||||
|
async fn accounts(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<Vec<u32>> {
|
||||||
|
let mut covered = Vec::new();
|
||||||
|
for id in server
|
||||||
|
.registry()
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
let account_id = id.document_id();
|
||||||
|
if let Some(member) = server.member_of(account_id).await
|
||||||
|
&& hold.scope.covers(&member)
|
||||||
|
{
|
||||||
|
covered.push(account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (account_id, kept) in inbuxa_features::undelete::data::kept_accounts(server.store()).await? {
|
||||||
|
if hold.scope.covers(&kept_member(account_id, &kept)) {
|
||||||
|
covered.push(account_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
covered.sort_unstable();
|
||||||
|
covered.dedup();
|
||||||
|
if !asked.is_empty() {
|
||||||
|
covered.retain(|id| asked.contains(id));
|
||||||
|
}
|
||||||
|
Ok(covered)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blob(server: &Server, hash: &[u8]) -> trc::Result<Option<Vec<u8>>> {
|
||||||
|
server.blob_store().get_blob(hash, 0..usize::MAX).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects `accounts` under `hold` into a ZIP. Returns its bytes and item
|
||||||
|
/// count.
|
||||||
|
pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(Vec<u8>, usize)> {
|
||||||
|
let keeping = Keeping::new(None, std::slice::from_ref(hold));
|
||||||
|
let data = server.store();
|
||||||
|
let mut out = Builder::new();
|
||||||
|
|
||||||
|
for account_id in accounts(server, hold, asked).await? {
|
||||||
|
let address = server.audit_account_name(account_id).await;
|
||||||
|
let base = format!("{}/", segment(&address));
|
||||||
|
let live = server.account(account_id).await.is_ok();
|
||||||
|
|
||||||
|
if live {
|
||||||
|
// Mail, by the folder it's in
|
||||||
|
let cache = server
|
||||||
|
.get_cached_messages(account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for message in cache.emails.items.iter() {
|
||||||
|
let Some(metadata_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||||
|
account_id,
|
||||||
|
Collection::Email,
|
||||||
|
message.document_id,
|
||||||
|
EmailField::Metadata,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let metadata = metadata_
|
||||||
|
.unarchive::<MessageMetadata>()
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
let received = metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK;
|
||||||
|
if !keeping.covers(Some(received)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let folder = message
|
||||||
|
.mailboxes
|
||||||
|
.first()
|
||||||
|
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
|
||||||
|
.map(|b| b.path.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
|
||||||
|
if let Some(bytes) = blob(server, hash.as_slice()).await? {
|
||||||
|
let path = format!(
|
||||||
|
"{base}mail/{}/{}.eml",
|
||||||
|
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
|
||||||
|
Id::from(message.document_id)
|
||||||
|
);
|
||||||
|
out.add(path, &bytes, &address, "email", &folder, Some(received as i64), false)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendars, contacts and files, by their DAV paths
|
||||||
|
for (sync, kind) in [
|
||||||
|
(SyncCollection::Calendar, "event"),
|
||||||
|
(SyncCollection::AddressBook, "contact"),
|
||||||
|
(SyncCollection::FileNode, "file"),
|
||||||
|
] {
|
||||||
|
let resources = server
|
||||||
|
.fetch_dav_resources(account_id, account_id, sync)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
for path in resources.paths.iter() {
|
||||||
|
let Some(resource) = resources.resources.get(path.resource_idx) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let folder = path.path.rsplit_once('/').map(|(f, _)| f).unwrap_or_default();
|
||||||
|
let zip_path = |ext: Option<&str>| {
|
||||||
|
let mut p = format!(
|
||||||
|
"{base}{}/{}",
|
||||||
|
match kind {
|
||||||
|
"event" => "calendar",
|
||||||
|
"contact" => "contacts",
|
||||||
|
_ => "files",
|
||||||
|
},
|
||||||
|
path.path.split('/').map(segment).collect::<Vec<_>>().join("/")
|
||||||
|
);
|
||||||
|
if let Some(ext) = ext
|
||||||
|
&& !p.ends_with(ext)
|
||||||
|
{
|
||||||
|
p.push_str(ext);
|
||||||
|
}
|
||||||
|
p
|
||||||
|
};
|
||||||
|
use common::DavResourceMetadata as M;
|
||||||
|
match &resource.data {
|
||||||
|
M::CalendarEvent { start, .. } => {
|
||||||
|
if !keeping.covers_event(Some((*start).max(0) as u64)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(event_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::CalendarEvent,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
|
||||||
|
let text = event.data.event.to_string();
|
||||||
|
out.add(zip_path(Some(".ics")), text.as_bytes(), &address, kind, folder, Some(*start), false)?;
|
||||||
|
}
|
||||||
|
M::ContactCard { .. } => {
|
||||||
|
let Some(card_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::ContactCard,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
|
||||||
|
let mut text = String::with_capacity(256);
|
||||||
|
let _ = card.card.write_to(&mut text, server.core.groupware.vcard_version);
|
||||||
|
out.add(zip_path(Some(".vcf")), text.as_bytes(), &address, kind, folder, None, false)?;
|
||||||
|
}
|
||||||
|
M::File { size: Some(_), .. } => {
|
||||||
|
let Some(file_) = data
|
||||||
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||||
|
account_id,
|
||||||
|
Collection::FileNode,
|
||||||
|
resource.document_id,
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
|
||||||
|
let Some(props) = file.file.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
|
||||||
|
if let Some(bytes) = blob(server, hash.as_slice()).await? {
|
||||||
|
out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the hold keeps of what was deleted
|
||||||
|
for (id, item) in records::of_account(data, server.registry(), account_id).await? {
|
||||||
|
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (kind, ext, date) = match &item {
|
||||||
|
ArchivedItem::Email(e) => ("email", ".eml", Some(e.received_at.timestamp())),
|
||||||
|
ArchivedItem::CalendarEvent(e) => ("event", ".ics", e.start_time.map(|t| t.timestamp())),
|
||||||
|
ArchivedItem::ContactCard(_) => ("contact", ".vcf", None),
|
||||||
|
ArchivedItem::FileNode(_) => ("file", "", None),
|
||||||
|
ArchivedItem::SieveScript(_) => ("sieve", ".sieve", None),
|
||||||
|
};
|
||||||
|
// Kept by this hold, not only by another one over the same account
|
||||||
|
let in_range = match kind {
|
||||||
|
"event" => keeping.covers_event(date.map(|d| d.max(0) as u64)),
|
||||||
|
_ => keeping.covers(date.map(|d| d.max(0) as u64)),
|
||||||
|
};
|
||||||
|
if !in_range {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match &item {
|
||||||
|
ArchivedItem::FileNode(f) => segment(&f.name),
|
||||||
|
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
|
||||||
|
_ => format!("{id}{ext}"),
|
||||||
|
};
|
||||||
|
if let Some(bytes) = blob(server, item.blob_id().hash.as_slice()).await? {
|
||||||
|
out.add(format!("{base}archived/{kind}/{name}"), &bytes, &address, kind, "", date, true)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_safe_in_a_zip() {
|
||||||
|
assert_eq!(segment("../etc/passwd"), "_etc_passwd");
|
||||||
|
assert_eq!(segment(" "), "_");
|
||||||
|
assert_eq!(segment("Q3 report.pdf"), "Q3 report.pdf");
|
||||||
|
assert_eq!(csv("a,b"), "\"a,b\"");
|
||||||
|
assert_eq!(csv("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zip_carries_its_manifest_and_its_hash() {
|
||||||
|
let mut b = Builder::new();
|
||||||
|
b.add("[email protected]/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
||||||
|
.unwrap();
|
||||||
|
b.add("[email protected]/mail/INBOX/b.eml".into(), b"other", "[email protected]", "email", "INBOX", None, true)
|
||||||
|
.unwrap();
|
||||||
|
let (bytes, items) = b.finish().unwrap();
|
||||||
|
assert_eq!(items, 2);
|
||||||
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||||
|
let mut manifest = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||||
|
assert!(manifest.contains("[email protected]/mail/INBOX/b (2).eml"), "{manifest}");
|
||||||
|
let mut hash = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||||
|
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:HoldExport` (audit-hold-lock spec, LH-12): starting a collection
|
||||||
|
//! of what a hold keeps, and seeing how it went. The ZIP is the creator's
|
||||||
|
//! blob, to download once it's ready. Creating one is audited, with its
|
||||||
|
//! reason (AU-1.9, AU-12).
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken};
|
||||||
|
use inbuxa_features::hold::{self, Export, ExportStatus};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_hold_export::{
|
||||||
|
HoldExport, HoldExportProperty as P, HoldExportSetArguments, HoldExportValue,
|
||||||
|
},
|
||||||
|
types::date::UTCDate,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use store::write::now;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type LValue = Value<'static, P, HoldExportValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[
|
||||||
|
P::Id,
|
||||||
|
P::HoldId,
|
||||||
|
P::AccountIds,
|
||||||
|
P::Reason,
|
||||||
|
P::Status,
|
||||||
|
P::CreatedAt,
|
||||||
|
P::CreatedBy,
|
||||||
|
P::FinishedAt,
|
||||||
|
P::BlobId,
|
||||||
|
P::Size,
|
||||||
|
P::Items,
|
||||||
|
P::Sha256,
|
||||||
|
P::Error,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn date(seconds: u64) -> LValue {
|
||||||
|
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_text(value: &Option<String>) -> LValue {
|
||||||
|
value.as_ref().map_or(Value::Null, |v| Value::Str(v.clone().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(export: &Export, properties: &[P]) -> LValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id => Value::Element(HoldExportValue::Id(Id::from(export.id))),
|
||||||
|
P::HoldId => Value::Str(Id::from(export.hold_id).to_string().into()),
|
||||||
|
P::AccountIds => Value::Array(
|
||||||
|
export
|
||||||
|
.accounts
|
||||||
|
.iter()
|
||||||
|
.map(|id| Value::Str(Id::from(*id).to_string().into()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
P::Reason => Value::Str(export.reason.clone().into()),
|
||||||
|
P::Status => Value::Str(
|
||||||
|
match export.status {
|
||||||
|
ExportStatus::Running => "running",
|
||||||
|
ExportStatus::Ready => "ready",
|
||||||
|
ExportStatus::Failed => "failed",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
P::CreatedAt => date(export.created_at),
|
||||||
|
P::CreatedBy => Value::Str(export.created_by.clone().into()),
|
||||||
|
P::FinishedAt => export.finished_at.map_or(Value::Null, date),
|
||||||
|
P::BlobId => opt_text(&export.blob_id),
|
||||||
|
P::Size => Value::Number(export.size.into()),
|
||||||
|
P::Items => Value::Number(export.items.into()),
|
||||||
|
P::Sha256 => opt_text(&export.sha256),
|
||||||
|
P::Error => opt_text(&export.error),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:HoldExport/get`: every export, newest first.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
mut request: GetRequest<HoldExport>,
|
||||||
|
) -> trc::Result<GetResponse<HoldExport>> {
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let mut exports = hold::exports(server.store()).await?;
|
||||||
|
exports.reverse();
|
||||||
|
match ids {
|
||||||
|
None => response
|
||||||
|
.list
|
||||||
|
.extend(exports.iter().map(|e| to_value(e, &properties))),
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
match exports.iter().find(|e| u64::from(e.id) == id.id()) {
|
||||||
|
Some(export) => response.list.push(to_value(export, &properties)),
|
||||||
|
None => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property)
|
||||||
|
.with_description(why.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:HoldExport/set`: create starts an export; nothing else is
|
||||||
|
/// allowed. The request layer records it with its reason.
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, HoldExport>,
|
||||||
|
) -> trc::Result<SetResponse<HoldExport>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
let arguments: HoldExportSetArguments = std::mem::take(&mut request.arguments);
|
||||||
|
let data = server.store();
|
||||||
|
let actor = server.audit_actor(access_token).await;
|
||||||
|
|
||||||
|
'create: for (client_id, value) in request.unwrap_create() {
|
||||||
|
let mut hold_id = None;
|
||||||
|
let mut accounts = Vec::new();
|
||||||
|
let mut reason = arguments.reason.clone();
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
match (&key, &value) {
|
||||||
|
(Key::Property(P::HoldId), Value::Str(id)) => {
|
||||||
|
hold_id = Id::from_str(id).ok().and_then(|id| u32::try_from(id.id()).ok())
|
||||||
|
}
|
||||||
|
(Key::Property(P::AccountIds), Value::Array(items)) => {
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
Value::Str(id) => match Id::from_str(id) {
|
||||||
|
Ok(id) => accounts.push(id.document_id()),
|
||||||
|
Err(_) => {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Key::Property(P::Reason), Value::Str(r)) => reason = Some(r.to_string()),
|
||||||
|
_ => {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::invalid_properties().with_property(key.clone().into_owned()),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(reason) = reason
|
||||||
|
.map(|r| r.trim().chars().take(500).collect::<String>())
|
||||||
|
.filter(|r| !r.is_empty())
|
||||||
|
else {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::Reason, "Say why: a reason is required and is kept in the audit log."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let hold = match hold_id {
|
||||||
|
Some(id) => hold::get(data, id).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let Some(hold) = hold else {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, invalid(P::HoldId, "No such legal hold."));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !hold.is_active() {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
invalid(P::HoldId, "That hold was released; export while a hold is in place."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(created_by_id) = actor.account_id else {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, SetError::forbidden().with_description("Sign in as a person to export."));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
accounts.sort_unstable();
|
||||||
|
accounts.dedup();
|
||||||
|
let export = Export {
|
||||||
|
id: 0,
|
||||||
|
hold_id: hold.id,
|
||||||
|
accounts,
|
||||||
|
reason,
|
||||||
|
created_at: now(),
|
||||||
|
created_by: actor.name.clone(),
|
||||||
|
created_by_id,
|
||||||
|
status: ExportStatus::Running,
|
||||||
|
finished_at: None,
|
||||||
|
blob_id: None,
|
||||||
|
size: 0,
|
||||||
|
items: 0,
|
||||||
|
sha256: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let id = hold::create_export(data, &export).await?;
|
||||||
|
let export = Export { id, ..export };
|
||||||
|
|
||||||
|
// The collection runs on its own; get says when it's ready
|
||||||
|
let server = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut done = export.clone();
|
||||||
|
match crate::inbuxa::hold_export::build(&server, &hold, &export.accounts).await {
|
||||||
|
Ok((bytes, items)) => match server.put_jmap_blob(export.created_by_id, &bytes).await {
|
||||||
|
Ok(blob) => {
|
||||||
|
done.status = ExportStatus::Ready;
|
||||||
|
done.blob_id = Some(blob.to_string());
|
||||||
|
done.size = bytes.len() as u64;
|
||||||
|
done.items = items as u64;
|
||||||
|
done.sha256 = Some(
|
||||||
|
Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
done.status = ExportStatus::Failed;
|
||||||
|
done.error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
done.status = ExportStatus::Failed;
|
||||||
|
done.error = Some(
|
||||||
|
err.value_as_str(trc::Key::Details)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| err.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done.finished_at = Some(now());
|
||||||
|
if let Err(err) = hold::update_export(server.store(), &done).await {
|
||||||
|
trc::error!(err.details("Failed to save a legal hold export's result"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut out = Map::with_capacity(1);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::Id),
|
||||||
|
Value::Element(HoldExportValue::Id(Id::from(id))),
|
||||||
|
);
|
||||||
|
response.created.insert(client_id, Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, _) in request.unwrap_update() {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("An export can't be changed; start a new one."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("Exports stay listed; the file expires on its own."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:LegalHold` (audit-hold-lock spec, LH-1 to LH-14): placing,
|
||||||
|
//! widening and releasing holds. Only server-level administrators reach
|
||||||
|
//! this: the tenant ceiling strips the permissions from everyone in a
|
||||||
|
//! tenant (LH-13). What a hold keeps is the undelete hooks' job.
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken, hold::HoldSummary};
|
||||||
|
use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_legal_hold::{
|
||||||
|
LegalHold, LegalHoldProperty as P, LegalHoldSetArguments, LegalHoldValue,
|
||||||
|
},
|
||||||
|
request::IntoValid,
|
||||||
|
types::date::UTCDate,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use store::write::now;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type LValue = Value<'static, P, LegalHoldValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[
|
||||||
|
P::Id,
|
||||||
|
P::Name,
|
||||||
|
P::Reference,
|
||||||
|
P::Description,
|
||||||
|
P::Scope,
|
||||||
|
P::From,
|
||||||
|
P::To,
|
||||||
|
P::PlacedAt,
|
||||||
|
P::PlacedBy,
|
||||||
|
P::Released,
|
||||||
|
P::ReleasedAt,
|
||||||
|
P::ReleasedBy,
|
||||||
|
P::ReleaseReason,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The longest a name, reference or description may be.
|
||||||
|
const MAX_TEXT: usize = 500;
|
||||||
|
|
||||||
|
fn date(seconds: u64) -> LValue {
|
||||||
|
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: &Option<String>) -> LValue {
|
||||||
|
value
|
||||||
|
.as_ref()
|
||||||
|
.map_or(Value::Null, |v| Value::Str(v.clone().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(list: &[u32]) -> LValue {
|
||||||
|
Value::Array(
|
||||||
|
list.iter()
|
||||||
|
.map(|id| Value::Str(Id::from(*id).to_string().into()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(hold: &Hold, properties: &[P], summary: Option<&HoldSummary>) -> LValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id => Value::Element(LegalHoldValue::Id(Id::from(hold.id))),
|
||||||
|
P::Name => Value::Str(hold.name.clone().into()),
|
||||||
|
P::Reference => text(&hold.reference),
|
||||||
|
P::Description => text(&hold.description),
|
||||||
|
P::Scope => {
|
||||||
|
let mut scope = Map::with_capacity(5);
|
||||||
|
scope.insert_unchecked(Key::Borrowed("server"), Value::Bool(hold.scope.server));
|
||||||
|
scope.insert_unchecked(Key::Borrowed("accounts"), ids(&hold.scope.accounts));
|
||||||
|
scope.insert_unchecked(Key::Borrowed("groups"), ids(&hold.scope.groups));
|
||||||
|
scope.insert_unchecked(Key::Borrowed("domains"), ids(&hold.scope.domains));
|
||||||
|
scope.insert_unchecked(Key::Borrowed("tenants"), ids(&hold.scope.tenants));
|
||||||
|
Value::Object(scope)
|
||||||
|
}
|
||||||
|
P::From => hold.from.map_or(Value::Null, date),
|
||||||
|
P::To => hold.to.map_or(Value::Null, date),
|
||||||
|
P::Reason => Value::Null,
|
||||||
|
P::PlacedAt => date(hold.placed_at),
|
||||||
|
P::PlacedBy => Value::Str(hold.placed_by.clone().into()),
|
||||||
|
P::Released => Value::Bool(!hold.is_active()),
|
||||||
|
P::ReleasedAt => hold.released.as_ref().map_or(Value::Null, |r| date(r.at)),
|
||||||
|
P::ReleasedBy => hold
|
||||||
|
.released
|
||||||
|
.as_ref()
|
||||||
|
.map_or(Value::Null, |r| Value::Str(r.by.clone().into())),
|
||||||
|
P::ReleaseReason => hold
|
||||||
|
.released
|
||||||
|
.as_ref()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:LegalHold/get`: every hold, released ones included (LH-1).
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
mut request: GetRequest<LegalHold>,
|
||||||
|
) -> trc::Result<GetResponse<LegalHold>> {
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
// LH-14: the holds on one account, whether it's live or deleted and kept
|
||||||
|
if let Some(account) = request.arguments.covering_account.take() {
|
||||||
|
let account_id = account.document_id();
|
||||||
|
let covering = match server.member_of(account_id).await {
|
||||||
|
Some(member) => hold::covering(data, &member).await?,
|
||||||
|
None => match inbuxa_features::undelete::data::kept_account(data, account_id).await? {
|
||||||
|
Some(kept) => {
|
||||||
|
hold::covering(data, &common::hold::kept_member(account_id, &kept)).await?
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for current in covering {
|
||||||
|
response
|
||||||
|
.list
|
||||||
|
.push(to_value(¤t, &properties, summaries.get(¤t.id)));
|
||||||
|
}
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
match ids {
|
||||||
|
None => {
|
||||||
|
for current in hold::all(data).await? {
|
||||||
|
response
|
||||||
|
.list
|
||||||
|
.push(to_value(¤t, &properties, summaries.get(¤t.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
match u32::try_from(id.id())
|
||||||
|
.ok()
|
||||||
|
.map(|id| hold::get(data, id))
|
||||||
|
{
|
||||||
|
Some(found) => match found.await? {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_of(reason: Option<&str>) -> Option<String> {
|
||||||
|
reason
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|r| !r.is_empty())
|
||||||
|
.map(|r| r.chars().take(MAX_TEXT).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reason_required() -> SetError<P> {
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(P::Reason)
|
||||||
|
.with_description("Say why: a reason is required and is kept in the audit log.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused(refusal: Refusal) -> SetError<P> {
|
||||||
|
let property = match refusal {
|
||||||
|
Refusal::Released => P::Released,
|
||||||
|
Refusal::Narrowed | Refusal::Backwards => P::From,
|
||||||
|
Refusal::ScopeShrunk | Refusal::EmptyScope => P::Scope,
|
||||||
|
};
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property)
|
||||||
|
.with_description(refusal.describe())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property)
|
||||||
|
.with_description(why.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text property: a string, trimmed and capped, or null for none.
|
||||||
|
fn parse_text(
|
||||||
|
property: P,
|
||||||
|
value: &Value<'_, P, LegalHoldValue>,
|
||||||
|
required: bool,
|
||||||
|
) -> Result<Option<String>, SetError<P>> {
|
||||||
|
match value {
|
||||||
|
Value::Str(s) => {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
if required {
|
||||||
|
Err(invalid(property, "This can't be empty."))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(Some(s.chars().take(MAX_TEXT).collect()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Null if !required => Ok(None),
|
||||||
|
_ => Err(invalid(property, "Expected text.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_date(property: P, value: &Value<'_, P, LegalHoldValue>) -> Result<Option<u64>, SetError<P>> {
|
||||||
|
match value {
|
||||||
|
Value::Null => Ok(None),
|
||||||
|
Value::Str(s) => UTCDate::from_str(s)
|
||||||
|
.ok()
|
||||||
|
.map(|d| Some(d.timestamp().max(0) as u64))
|
||||||
|
.ok_or_else(|| invalid(property, "Expected a UTC date, or null.")),
|
||||||
|
_ => Err(invalid(property, "Expected a UTC date, or null.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a scope and checks that every account, group, domain and tenant
|
||||||
|
/// it names exists and is the right kind (LH-1).
|
||||||
|
async fn parse_scope(server: &Server, value: &Value<'_, P, LegalHoldValue>) -> Result<Scope, SetError<P>> {
|
||||||
|
let Value::Object(map) = value else {
|
||||||
|
return Err(invalid(P::Scope, "Expected an object."));
|
||||||
|
};
|
||||||
|
let mut scope = Scope::default();
|
||||||
|
for (key, value) in map.iter() {
|
||||||
|
let name: String = key.to_string().to_string();
|
||||||
|
if name == "server" {
|
||||||
|
match value {
|
||||||
|
Value::Bool(b) => scope.server = *b,
|
||||||
|
_ => return Err(invalid(P::Scope, "`server` must be true or false.")),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Value::Array(items) = value else {
|
||||||
|
return Err(invalid(P::Scope, &format!("`{name}` must be a list of ids.")));
|
||||||
|
};
|
||||||
|
let mut list = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
let id = match item {
|
||||||
|
Value::Str(s) => Id::from_str(s).ok(),
|
||||||
|
Value::Element(LegalHoldValue::Id(id)) => Some(*id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.and_then(|id| u32::try_from(id.id()).ok())
|
||||||
|
.ok_or_else(|| invalid(P::Scope, &format!("`{name}` must be a list of ids.")))?;
|
||||||
|
list.push(id);
|
||||||
|
}
|
||||||
|
for id in &list {
|
||||||
|
let exists = match name.as_str() {
|
||||||
|
"accounts" => server.account(*id).await.is_ok_and(|a| a.is_user_account()),
|
||||||
|
"groups" => server.account(*id).await.is_ok_and(|a| !a.is_user_account()),
|
||||||
|
"domains" => server.domain_by_id(*id).await.ok().flatten().is_some(),
|
||||||
|
"tenants" => server.tenant(*id).await.is_ok(),
|
||||||
|
_ => return Err(invalid(P::Scope, &format!("Unknown scope entry `{name}`."))),
|
||||||
|
};
|
||||||
|
if !exists {
|
||||||
|
return Err(invalid(
|
||||||
|
P::Scope,
|
||||||
|
&format!("No such {} as {}.", name.trim_end_matches('s'), Id::from(*id)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match name.as_str() {
|
||||||
|
"accounts" => scope.accounts = list,
|
||||||
|
"groups" => scope.groups = list,
|
||||||
|
"domains" => scope.domains = list,
|
||||||
|
_ => scope.tenants = list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:LegalHold/set`: create places a hold; update renames it, widens
|
||||||
|
/// its range or scope, or releases it; destroy is refused (LH-13). The
|
||||||
|
/// request layer records each, with its reason.
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, LegalHold>,
|
||||||
|
) -> trc::Result<SetResponse<LegalHold>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
let arguments: LegalHoldSetArguments = std::mem::take(&mut request.arguments);
|
||||||
|
let data = server.store();
|
||||||
|
let actor = server.audit_actor(access_token).await;
|
||||||
|
|
||||||
|
'create: for (client_id, value) in request.unwrap_create() {
|
||||||
|
let mut new = Hold {
|
||||||
|
id: 0,
|
||||||
|
name: String::new(),
|
||||||
|
reference: None,
|
||||||
|
description: None,
|
||||||
|
scope: Scope::default(),
|
||||||
|
from: None,
|
||||||
|
to: None,
|
||||||
|
placed_at: now(),
|
||||||
|
placed_by: actor.name.clone(),
|
||||||
|
placed_by_id: actor.account_id,
|
||||||
|
released: None,
|
||||||
|
};
|
||||||
|
let mut reason = reason_of(arguments.reason.as_deref());
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
let parsed = match &key {
|
||||||
|
Key::Property(P::Name) => parse_text(P::Name, &value, true).map(|v| {
|
||||||
|
new.name = v.unwrap_or_default();
|
||||||
|
}),
|
||||||
|
Key::Property(P::Reference) => {
|
||||||
|
parse_text(P::Reference, &value, false).map(|v| new.reference = v)
|
||||||
|
}
|
||||||
|
Key::Property(P::Description) => {
|
||||||
|
parse_text(P::Description, &value, false).map(|v| new.description = v)
|
||||||
|
}
|
||||||
|
Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| new.scope = v),
|
||||||
|
Key::Property(P::From) => parse_date(P::From, &value).map(|v| new.from = v),
|
||||||
|
Key::Property(P::To) => parse_date(P::To, &value).map(|v| new.to = v),
|
||||||
|
Key::Property(P::Reason) => {
|
||||||
|
if let Value::Str(r) = &value {
|
||||||
|
reason = reason_of(Some(r)).or(reason);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())),
|
||||||
|
};
|
||||||
|
if let Err(error) = parsed {
|
||||||
|
response.not_created.append(client_id, error);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if new.name.is_empty() {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, invalid(P::Name, "A hold needs a case name."));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if reason.is_none() {
|
||||||
|
response.not_created.append(client_id, reason_required());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(refusal) = new.check_new() {
|
||||||
|
response.not_created.append(client_id, refused(refusal));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = hold::create(data, &new).await?;
|
||||||
|
let mut out = Map::with_capacity(1);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::Id),
|
||||||
|
Value::Element(LegalHoldValue::Id(Id::from(id))),
|
||||||
|
);
|
||||||
|
response.created.insert(client_id, Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
'update: for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
let Some(current) = (match u32::try_from(id.id()) {
|
||||||
|
Ok(hold_id) => hold::get(data, hold_id).await?,
|
||||||
|
Err(_) => None,
|
||||||
|
}) else {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(reason) = reason_of(arguments.reason.as_deref()) else {
|
||||||
|
response.not_updated.append(id, reason_required());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut next = current.clone();
|
||||||
|
let mut release = false;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
let parsed = match &key {
|
||||||
|
Key::Property(P::Name) => {
|
||||||
|
parse_text(P::Name, &value, true).map(|v| next.name = v.unwrap_or_default())
|
||||||
|
}
|
||||||
|
Key::Property(P::Reference) => {
|
||||||
|
parse_text(P::Reference, &value, false).map(|v| next.reference = v)
|
||||||
|
}
|
||||||
|
Key::Property(P::Description) => {
|
||||||
|
parse_text(P::Description, &value, false).map(|v| next.description = v)
|
||||||
|
}
|
||||||
|
Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| next.scope = v),
|
||||||
|
Key::Property(P::From) => parse_date(P::From, &value).map(|v| next.from = v),
|
||||||
|
Key::Property(P::To) => parse_date(P::To, &value).map(|v| next.to = v),
|
||||||
|
Key::Property(P::Released) => match value {
|
||||||
|
Value::Bool(true) => {
|
||||||
|
release = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Value::Bool(false) if current.is_active() => Ok(()),
|
||||||
|
_ => Err(invalid(
|
||||||
|
P::Released,
|
||||||
|
"A released hold can't be put back; place a new one instead.",
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
_ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())),
|
||||||
|
};
|
||||||
|
if let Err(error) = parsed {
|
||||||
|
response.not_updated.append(id, error);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(refusal) = current.check_update(&mut next) {
|
||||||
|
response.not_updated.append(id, refused(refusal));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if release {
|
||||||
|
next.released = Some(Release {
|
||||||
|
at: now(),
|
||||||
|
by: actor.name.clone(),
|
||||||
|
by_id: actor.account_id,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if next != current {
|
||||||
|
hold::update(data, &next).await?;
|
||||||
|
}
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LH-6, LH-10, LH-11: the archive follows what's now held
|
||||||
|
if !response.created.is_empty() || !response.updated.is_empty() {
|
||||||
|
server.settle_archive().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden()
|
||||||
|
.with_description("A hold is never deleted. Release it, and it stays listed."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod account_lock;
|
pub mod account_lock;
|
||||||
|
pub mod legal_hold;
|
||||||
|
pub mod hold_export;
|
||||||
|
pub mod hold_export_api;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod audit_log;
|
pub mod audit_log;
|
||||||
pub mod ai_limits;
|
pub mod ai_limits;
|
||||||
|
|||||||
@@ -298,6 +298,33 @@ pub(crate) async fn set(mut set: RegistrySetResponse<'_>) -> trc::Result<Registr
|
|||||||
|
|
||||||
for id in std::mem::take(&mut set.destroy) {
|
for id in std::mem::take(&mut set.destroy) {
|
||||||
match undelete::records::get(data, registry, account_id, id).await? {
|
match undelete::records::get(data, registry, account_id, id).await? {
|
||||||
|
// inbuxa: LH-7: a held item can't be destroyed; restoring it
|
||||||
|
// still can. The hold is named only to those who may see holds.
|
||||||
|
Some(item)
|
||||||
|
if inbuxa_features::hold::is_held_until(
|
||||||
|
item.archived_until().timestamp().max(0) as u64,
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
let mut why = "A legal hold applies to this item, so it can't be deleted.".to_string();
|
||||||
|
if set
|
||||||
|
.access_token
|
||||||
|
.has_permission(registry::schema::enums::Permission::SysLegalHoldGet)
|
||||||
|
{
|
||||||
|
let names = set
|
||||||
|
.server
|
||||||
|
.holds_on(account_id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|hold| hold.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !names.is_empty() {
|
||||||
|
why = format!("Held by {}, so it can't be deleted.", names.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.response
|
||||||
|
.not_destroyed
|
||||||
|
.append(id, SetError::forbidden().with_description(why));
|
||||||
|
}
|
||||||
Some(item) => {
|
Some(item) => {
|
||||||
undelete::records::remove(data, registry, id, &item).await?;
|
undelete::records::remove(data, registry, id, &item).await?;
|
||||||
set.response.destroyed.push(id);
|
set.response.destroyed.push(id);
|
||||||
|
|||||||
@@ -1739,6 +1739,11 @@ pub enum Permission {
|
|||||||
SysAccountLockCreate = 666,
|
SysAccountLockCreate = 666,
|
||||||
SysAccountLockUpdate = 667,
|
SysAccountLockUpdate = 667,
|
||||||
SysAccountLockDestroy = 668,
|
SysAccountLockDestroy = 668,
|
||||||
|
// inbuxa: legal hold (audit-hold-lock spec, LH-13)
|
||||||
|
SysLegalHoldGet = 669,
|
||||||
|
SysLegalHoldCreate = 670,
|
||||||
|
SysLegalHoldUpdate = 671,
|
||||||
|
SysLegalHoldExport = 672,
|
||||||
SysAccountGet = 219,
|
SysAccountGet = 219,
|
||||||
SysAccountCreate = 220,
|
SysAccountCreate = 220,
|
||||||
SysAccountUpdate = 221,
|
SysAccountUpdate = 221,
|
||||||
|
|||||||
@@ -7080,6 +7080,10 @@ impl EnumImpl for Permission {
|
|||||||
b"sysAccountLockCreate" => Permission::SysAccountLockCreate,
|
b"sysAccountLockCreate" => Permission::SysAccountLockCreate,
|
||||||
b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate,
|
b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate,
|
||||||
b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy,
|
b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy,
|
||||||
|
b"sysLegalHoldGet" => Permission::SysLegalHoldGet,
|
||||||
|
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
|
||||||
|
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
|
||||||
|
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
|
||||||
b"sysAccountGet" => Permission::SysAccountGet,
|
b"sysAccountGet" => Permission::SysAccountGet,
|
||||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||||
@@ -7765,6 +7769,10 @@ impl EnumImpl for Permission {
|
|||||||
Permission::SysAccountLockCreate => "sysAccountLockCreate",
|
Permission::SysAccountLockCreate => "sysAccountLockCreate",
|
||||||
Permission::SysAccountLockUpdate => "sysAccountLockUpdate",
|
Permission::SysAccountLockUpdate => "sysAccountLockUpdate",
|
||||||
Permission::SysAccountLockDestroy => "sysAccountLockDestroy",
|
Permission::SysAccountLockDestroy => "sysAccountLockDestroy",
|
||||||
|
Permission::SysLegalHoldGet => "sysLegalHoldGet",
|
||||||
|
Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
|
||||||
|
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
|
||||||
|
Permission::SysLegalHoldExport => "sysLegalHoldExport",
|
||||||
Permission::SysAccountGet => "sysAccountGet",
|
Permission::SysAccountGet => "sysAccountGet",
|
||||||
Permission::SysAccountCreate => "sysAccountCreate",
|
Permission::SysAccountCreate => "sysAccountCreate",
|
||||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||||
@@ -8443,6 +8451,10 @@ impl EnumImpl for Permission {
|
|||||||
666 => Some(Permission::SysAccountLockCreate),
|
666 => Some(Permission::SysAccountLockCreate),
|
||||||
667 => Some(Permission::SysAccountLockUpdate),
|
667 => Some(Permission::SysAccountLockUpdate),
|
||||||
668 => Some(Permission::SysAccountLockDestroy),
|
668 => Some(Permission::SysAccountLockDestroy),
|
||||||
|
669 => Some(Permission::SysLegalHoldGet),
|
||||||
|
670 => Some(Permission::SysLegalHoldCreate),
|
||||||
|
671 => Some(Permission::SysLegalHoldUpdate),
|
||||||
|
672 => Some(Permission::SysLegalHoldExport),
|
||||||
219 => Some(Permission::SysAccountGet),
|
219 => Some(Permission::SysAccountGet),
|
||||||
220 => Some(Permission::SysAccountCreate),
|
220 => Some(Permission::SysAccountCreate),
|
||||||
221 => Some(Permission::SysAccountUpdate),
|
221 => Some(Permission::SysAccountUpdate),
|
||||||
@@ -8887,7 +8899,7 @@ impl EnumImpl for Permission {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNT: usize = 669;
|
const COUNT: usize = 673;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for Permission {
|
impl serde::Serialize for Permission {
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ impl DestroyAccountTask for Server {
|
|||||||
async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Result<TaskResult> {
|
async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Result<TaskResult> {
|
||||||
let account_id = task.account_id.document_id();
|
let account_id = task.account_id.document_id();
|
||||||
|
|
||||||
|
// inbuxa: LH-8, LH-10: a kept account waits for its time, and a held one
|
||||||
|
// for its release; "destroy now" clears the kept record first
|
||||||
|
if let Some(kept) =
|
||||||
|
inbuxa_features::undelete::data::kept_account(&server.core.storage.data, account_id).await?
|
||||||
|
{
|
||||||
|
let now = store::write::now();
|
||||||
|
let held = inbuxa_features::hold::is_held_until(kept.kept_until)
|
||||||
|
|| server.is_kept_held(account_id, &kept).await?;
|
||||||
|
if held || kept.kept_until > now {
|
||||||
|
let retry = if held { now + 86_400 } else { kept.kept_until };
|
||||||
|
return Ok(TaskResult::deferred(
|
||||||
|
Some(retry),
|
||||||
|
"The account is still kept: a legal hold applies, or its time hasn't come.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Destroy public keys and masked emails
|
// Destroy public keys and masked emails
|
||||||
for object in [ObjectType::PublicKey, ObjectType::MaskedEmail] {
|
for object in [ObjectType::PublicKey, ObjectType::MaskedEmail] {
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
|
|||||||
@@ -229,11 +229,19 @@ impl SearchIndexTask for Server {
|
|||||||
IndexDocumentType::Email => None,
|
IndexDocumentType::Email => None,
|
||||||
} && let Err(err) = archive_noted(self, kind, account_id, document_id).await
|
} && let Err(err) = archive_noted(self, kind, account_id, document_id).await
|
||||||
{
|
{
|
||||||
|
// inbuxa: LH-5: the note stays, so the retry archives
|
||||||
|
// it; nothing a hold keeps is lost to a failure
|
||||||
trc::error!(
|
trc::error!(
|
||||||
err.account_id(account_id)
|
err.account_id(account_id)
|
||||||
.document_id(document_id)
|
.document_id(document_id)
|
||||||
.details("Failed to archive a deleted item")
|
.details("Failed to archive a deleted item")
|
||||||
);
|
);
|
||||||
|
results.push(IndexTaskResult {
|
||||||
|
task_type: TaskType::Delete,
|
||||||
|
index: task.document_type,
|
||||||
|
result: TaskResult::temporary("Failed to archive a deleted item"),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
document_deletions[idx]
|
document_deletions[idx]
|
||||||
@@ -696,8 +704,9 @@ pub fn trace_search_document(
|
|||||||
document
|
document
|
||||||
}
|
}
|
||||||
|
|
||||||
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
|
// inbuxa: UD-1, UD-4, LH-4: archives a deleted file, event or contact noted
|
||||||
// deletion, when archiving is on; otherwise its note is dropped
|
// at deletion, when archiving is on or a hold covers it; otherwise its note is
|
||||||
|
// dropped. The note goes only once the item is archived.
|
||||||
async fn archive_noted(
|
async fn archive_noted(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
kind: undelete::groupware::Kind,
|
kind: undelete::groupware::Kind,
|
||||||
@@ -705,12 +714,22 @@ async fn archive_noted(
|
|||||||
document_id: u32,
|
document_id: u32,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
let data = &server.core.storage.data;
|
let data = &server.core.storage.data;
|
||||||
let Some(note) = undelete::groupware::take(data, kind, account_id, document_id).await? else {
|
let Some(note) = undelete::groupware::peek(data, kind, account_id, document_id).await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let Some(retention) = undelete::settings::retention(server.registry()).await?.items else {
|
// LH-3: events by their start; contacts and files whole
|
||||||
return Ok(());
|
let keeping = server.keeping(account_id).await?;
|
||||||
|
let held = match kind {
|
||||||
|
undelete::groupware::Kind::CalendarEvent => {
|
||||||
|
keeping.covers_event(undelete::groupware::event_start(¬e))
|
||||||
|
}
|
||||||
|
_ => keeping.covers(None),
|
||||||
};
|
};
|
||||||
|
let now = store::write::now();
|
||||||
|
let Some(until) = keeping.until(now, held) else {
|
||||||
|
return undelete::groupware::clear(data, kind, account_id, document_id).await;
|
||||||
|
};
|
||||||
|
let retention = until.saturating_sub(now);
|
||||||
let blob_hash = match (¬e.content, ¬e.blob_hash) {
|
let blob_hash = match (¬e.content, ¬e.blob_hash) {
|
||||||
(Some(text), _) => {
|
(Some(text), _) => {
|
||||||
server
|
server
|
||||||
@@ -723,11 +742,11 @@ async fn archive_noted(
|
|||||||
.into_err()
|
.into_err()
|
||||||
.details("Invalid blob hash in undelete note")
|
.details("Invalid blob hash in undelete note")
|
||||||
})?,
|
})?,
|
||||||
(None, None) => return Ok(()),
|
(None, None) => return undelete::groupware::clear(data, kind, account_id, document_id).await,
|
||||||
};
|
};
|
||||||
undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention)
|
undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention)
|
||||||
.await
|
.await?;
|
||||||
.map(|_| ())
|
undelete::groupware::clear(data, kind, account_id, document_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_email_metadata(
|
async fn delete_email_metadata(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
@@ -13,6 +15,8 @@ use mail_auth::{
|
|||||||
MX, RecordSet,
|
MX, RecordSet,
|
||||||
common::resolver::ToFqdn,
|
common::resolver::ToFqdn,
|
||||||
hickory_resolver::{
|
hickory_resolver::{
|
||||||
|
TokioResolver,
|
||||||
|
lookup::Lookup,
|
||||||
net::{DnsError, NetError},
|
net::{DnsError, NetError},
|
||||||
proto::{
|
proto::{
|
||||||
dnssec::Proof,
|
dnssec::Proof,
|
||||||
@@ -83,16 +87,15 @@ impl TlsaLookup for Server {
|
|||||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mx_lookup = match self
|
let (mx_lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
Name::from_str_relaxed::<&str>(key.as_ref())?,
|
||||||
.dnssec
|
RecordType::MX,
|
||||||
.resolver
|
)
|
||||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mx_lookup) => mx_lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
|
|||||||
.collect::<Arc<[MX]>>();
|
.collect::<Arc<[MX]>>();
|
||||||
let records = RecordSet {
|
let records = RecordSet {
|
||||||
rrset,
|
rrset,
|
||||||
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -285,16 +292,15 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||||
let lookup = match self
|
let (lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
name.clone(),
|
||||||
.dnssec
|
RecordType::A,
|
||||||
.resolver
|
)
|
||||||
.ipv4_lookup(name.clone())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(lookup) => lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Arc<[Ipv4Addr]>>(),
|
.collect::<Arc<[Ipv4Addr]>>(),
|
||||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
tlsa_base_status(&name, answers, RecordType::A)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -363,16 +373,15 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||||
let lookup = match self
|
let (lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
name.clone(),
|
||||||
.dnssec
|
RecordType::AAAA,
|
||||||
.resolver
|
)
|
||||||
.ipv6_lookup(name.clone())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(lookup) => lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Arc<[Ipv6Addr]>>(),
|
.collect::<Arc<[Ipv6Addr]>>(),
|
||||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
tlsa_base_status(&name, answers, RecordType::AAAA)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -415,6 +428,115 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then
|
||||||
|
// retries those hosts until the message expires. Two cases seen in production:
|
||||||
|
//
|
||||||
|
// - A zone delegated beneath an unsigned zone, such as `l.google.com` under
|
||||||
|
// `google.com`. To prove the delegation insecure, hickory wants an SOA
|
||||||
|
// record in the DS reply, and public resolvers often send none.
|
||||||
|
// - A signed CNAME to a signed name without the record type queried. Hickory
|
||||||
|
// checks the denial of existence against the name first asked for, not the
|
||||||
|
// target's, and rejects it.
|
||||||
|
//
|
||||||
|
// When hickory says bogus, check the answer again with lookups it gets right.
|
||||||
|
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
|
||||||
|
// the name's zone and its parents are looked up, nearest first. If one
|
||||||
|
// validates as unsigned, nothing below it can be signed, so the plain resolver
|
||||||
|
// answers and the result is insecure. If one validates as signed first, the
|
||||||
|
// verdict stands.
|
||||||
|
|
||||||
|
const MAX_BOGUS_ALIASES: usize = 8;
|
||||||
|
|
||||||
|
struct ValidatedLookup {
|
||||||
|
lookup: Lookup,
|
||||||
|
insecure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BogusRecheck {
|
||||||
|
Alias(Name),
|
||||||
|
Insecure,
|
||||||
|
Bogus,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validated_lookup(
|
||||||
|
dnssec: &TokioResolver,
|
||||||
|
plain: &TokioResolver,
|
||||||
|
name: Name,
|
||||||
|
record_type: RecordType,
|
||||||
|
) -> Result<ValidatedLookup, NetError> {
|
||||||
|
let mut query = name;
|
||||||
|
let mut aliases = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let err = match dnssec.lookup(query.clone(), record_type).await {
|
||||||
|
Ok(lookup) => {
|
||||||
|
return Ok(ValidatedLookup {
|
||||||
|
lookup,
|
||||||
|
insecure: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
match recheck_bogus(dnssec, &query).await {
|
||||||
|
BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => {
|
||||||
|
aliases += 1;
|
||||||
|
query = target;
|
||||||
|
}
|
||||||
|
BogusRecheck::Insecure => {
|
||||||
|
return plain
|
||||||
|
.lookup(query, record_type)
|
||||||
|
.await
|
||||||
|
.map(|lookup| ValidatedLookup {
|
||||||
|
lookup,
|
||||||
|
insecure: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck {
|
||||||
|
if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await
|
||||||
|
&& let Some(target) = secure_alias(name, lookup.answers())
|
||||||
|
{
|
||||||
|
return BogusRecheck::Alias(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut zone = name.clone();
|
||||||
|
while !zone.is_root() {
|
||||||
|
if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await {
|
||||||
|
match apex_status(&zone, lookup.answers()) {
|
||||||
|
Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure,
|
||||||
|
Some(DnssecStatus::Secure) => return BogusRecheck::Bogus,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zone = zone.base_name();
|
||||||
|
}
|
||||||
|
|
||||||
|
BogusRecheck::Bogus
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure_alias(query: &Name, answers: &[Record]) -> Option<Name> {
|
||||||
|
answers.iter().find_map(|record| match &record.data {
|
||||||
|
RData::CNAME(target) if &record.name == query && record.proof.is_secure() => {
|
||||||
|
Some(target.0.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apex_status(zone: &Name, answers: &[Record]) -> Option<DnssecStatus> {
|
||||||
|
answers
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.record_type() == RecordType::SOA && &record.name == zone)
|
||||||
|
.map(|record| proof_to_dnssec_status(record.proof))
|
||||||
|
.reduce(least_secure)
|
||||||
|
}
|
||||||
|
|
||||||
struct NegativeAnswer {
|
struct NegativeAnswer {
|
||||||
response_code: ResponseCode,
|
response_code: ResponseCode,
|
||||||
dnssec_status: DnssecStatus,
|
dnssec_status: DnssecStatus,
|
||||||
@@ -511,7 +633,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
|
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA};
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
fn name(value: &str) -> Name {
|
fn name(value: &str) -> Name {
|
||||||
@@ -624,4 +746,127 @@ mod tests {
|
|||||||
DnssecStatus::Insecure
|
DnssecStatus::Insecure
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn soa(owner: &str, proof: Proof) -> Record {
|
||||||
|
let mut record = Record::from_rdata(
|
||||||
|
name(owner),
|
||||||
|
3600,
|
||||||
|
RData::SOA(SOA::new(
|
||||||
|
name("ns1.example.org."),
|
||||||
|
name("hostmaster.example.org."),
|
||||||
|
1,
|
||||||
|
900,
|
||||||
|
900,
|
||||||
|
1800,
|
||||||
|
60,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
record.proof = proof;
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_alias_follows_signed_cname() {
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&name("mail.example.org."),
|
||||||
|
&[alias("mail.example.org.", "mx.example.net.", Proof::Secure)]
|
||||||
|
),
|
||||||
|
Some(name("mx.example.net."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_alias_ignores_unsigned_or_other_cname() {
|
||||||
|
let query = name("mail.example.org.");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&query,
|
||||||
|
&[alias(
|
||||||
|
"mail.example.org.",
|
||||||
|
"mx.example.net.",
|
||||||
|
Proof::Insecure
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&query,
|
||||||
|
&[alias(
|
||||||
|
"other.example.org.",
|
||||||
|
"mx.example.net.",
|
||||||
|
Proof::Secure
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apex_status_reads_the_zone_soa() {
|
||||||
|
let zone = name("example.com.");
|
||||||
|
|
||||||
|
for (proof, expected) in [
|
||||||
|
(Proof::Secure, Some(DnssecStatus::Secure)),
|
||||||
|
(Proof::Insecure, Some(DnssecStatus::Insecure)),
|
||||||
|
(Proof::Bogus, Some(DnssecStatus::Bogus)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
apex_status(&zone, &[soa("example.com.", proof)]),
|
||||||
|
expected,
|
||||||
|
"proof {proof}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apex_status_ignores_other_records() {
|
||||||
|
assert_eq!(
|
||||||
|
apex_status(
|
||||||
|
&name("example.com."),
|
||||||
|
&[
|
||||||
|
soa("sub.example.com.", Proof::Insecure),
|
||||||
|
address("example.com.", Proof::Insecure),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs the network: a signed MX pointing into a zone delegated beneath an
|
||||||
|
// unsigned one. Run with `--ignored` to check a hickory upgrade.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn validated_lookup_proves_delegation_below_unsigned_zone() {
|
||||||
|
use mail_auth::hickory_resolver::{
|
||||||
|
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
|
||||||
|
net::runtime::TokioRuntimeProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
let build = |validate: bool| {
|
||||||
|
// Same options as the server's DNSSEC resolver; hickory fails
|
||||||
|
// validation with concurrent requests.
|
||||||
|
let mut opts = ResolverOpts::default();
|
||||||
|
opts.validate = validate;
|
||||||
|
opts.num_concurrent_reqs = 1;
|
||||||
|
opts.cache_size = 0;
|
||||||
|
TokioResolver::builder_with_config(
|
||||||
|
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
|
||||||
|
TokioRuntimeProvider::default(),
|
||||||
|
)
|
||||||
|
.with_options(opts)
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (dnssec, plain) = (build(true), build(false));
|
||||||
|
|
||||||
|
let validated =
|
||||||
|
validated_lookup(&dnssec, &plain, name("aspmx.l.google.com."), RecordType::A)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(validated.insecure);
|
||||||
|
assert!(!validated.lookup.answers().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ use mail_auth::{
|
|||||||
common::verify::VerifySignature,
|
common::verify::VerifySignature,
|
||||||
dkim2::Dkim2Output,
|
dkim2::Dkim2Output,
|
||||||
dmarc::{self},
|
dmarc::{self},
|
||||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
report::{
|
||||||
|
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
|
||||||
|
SPFDomainScope,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
@@ -459,7 +462,7 @@ impl DmarcReporting for Server {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||||
let mut message = Vec::with_capacity(2048);
|
let mut message = Vec::with_capacity(2048);
|
||||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
|
||||||
&self
|
&self
|
||||||
.eval_if(
|
.eval_if(
|
||||||
&self.core.smtp.report.submitter,
|
&self.core.smtp.report.submitter,
|
||||||
@@ -710,3 +713,55 @@ impl DmarcReporting for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that
|
||||||
|
// passed DMARC under an enforcing policy. Cloudflare's report intake rejects
|
||||||
|
// the whole report with "555 5.7.1 invalid_report_schema" when it sees that
|
||||||
|
// value, and older parsers built on the RFC 7489 schema do the same. "none"
|
||||||
|
// (no action taken) is valid under both and says the same thing, so reports
|
||||||
|
// go out with that instead.
|
||||||
|
fn with_compatible_dispositions(mut report: Report) -> Report {
|
||||||
|
for record in &mut report.record {
|
||||||
|
let disposition = &mut record.row.policy_evaluated.disposition;
|
||||||
|
if *disposition == ActionDisposition::Pass {
|
||||||
|
*disposition = ActionDisposition::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mail_auth::report::DmarcResult;
|
||||||
|
|
||||||
|
fn record(disposition: ActionDisposition) -> Record {
|
||||||
|
Record::new()
|
||||||
|
.with_source_ip("192.0.2.1".parse().unwrap())
|
||||||
|
.with_count(1)
|
||||||
|
.with_action_disposition(disposition)
|
||||||
|
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||||
|
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||||
|
.with_header_from("example.org")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pass_disposition_is_reported_as_none() {
|
||||||
|
let xml = with_compatible_dispositions(
|
||||||
|
Report::new()
|
||||||
|
.with_domain("example.org")
|
||||||
|
.with_record(record(ActionDisposition::Pass))
|
||||||
|
.with_record(record(ActionDisposition::Quarantine))
|
||||||
|
.with_record(record(ActionDisposition::Reject)),
|
||||||
|
)
|
||||||
|
.to_xml();
|
||||||
|
|
||||||
|
assert!(!xml.contains("<disposition>pass</disposition>"), "{xml}");
|
||||||
|
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
|
||||||
|
assert!(
|
||||||
|
xml.contains("<disposition>quarantine</disposition>"),
|
||||||
|
"{xml}"
|
||||||
|
);
|
||||||
|
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! brand_version {
|
macro_rules! brand_version {
|
||||||
() => {
|
() => {
|
||||||
"2026.9.27.1"
|
"2026.9.28.2"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc
|
-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM
|
||||||
@@ -86,6 +86,7 @@ dns-update = { version = "0.5", features = ["test_provider"] }
|
|||||||
x509-parser = "0.18"
|
x509-parser = "0.18"
|
||||||
rcgen = "0.14"
|
rcgen = "0.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
zip = "8.6" # inbuxa: reading legal hold exports
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
testcontainers = { version = "0.28", features = ["reusable-containers"] }
|
testcontainers = { version = "0.28", features = ["reusable-containers"] }
|
||||||
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -174,7 +176,8 @@ async fn report_dmarc() {
|
|||||||
let source_ip = record.source_ip().unwrap();
|
let source_ip = record.source_ip().unwrap();
|
||||||
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
||||||
assert_eq!(record.count(), 2);
|
assert_eq!(record.count(), 2);
|
||||||
assert_eq!(record.action_disposition(), ActionDisposition::Pass);
|
// inbuxa: "pass" goes out as "none" for RFC 7489 parsers
|
||||||
|
assert_eq!(record.action_disposition(), ActionDisposition::None);
|
||||||
assert_eq!(record.envelope_from(), "[email protected]");
|
assert_eq!(record.envelope_from(), "[email protected]");
|
||||||
assert_eq!(record.header_from(), "[email protected]");
|
assert_eq!(record.header_from(), "[email protected]");
|
||||||
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const USING: &[&str] = &[
|
|||||||
"urn:ietf:params:jmap:core",
|
"urn:ietf:params:jmap:core",
|
||||||
"urn:ietf:params:jmap:mail",
|
"urn:ietf:params:jmap:mail",
|
||||||
"urn:ietf:params:jmap:submission",
|
"urn:ietf:params:jmap:submission",
|
||||||
|
"urn:ietf:params:jmap:calendars",
|
||||||
|
"urn:ietf:params:jmap:contacts",
|
||||||
|
"urn:ietf:params:jmap:filenode",
|
||||||
"urn:inbuxa:jmap",
|
"urn:inbuxa:jmap",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -179,6 +182,26 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
assert_eq!(delegation["access"], "read", "AL-7");
|
assert_eq!(delegation["access"], "read", "AL-7");
|
||||||
assert_eq!(delegation["sendAs"], false, "AL-7");
|
assert_eq!(delegation["sendAs"], false, "AL-7");
|
||||||
|
|
||||||
|
// AL-7: the whole account, not only mail: even a kind the owner holds
|
||||||
|
// none of (no files here) reads as empty rather than refused
|
||||||
|
for (method, arguments) in [
|
||||||
|
("FileNode/query", json!({"accountId": owner_id})),
|
||||||
|
("Calendar/get", json!({"accountId": owner_id, "ids": null})),
|
||||||
|
("AddressBook/get", json!({"accountId": owner_id, "ids": null})),
|
||||||
|
] {
|
||||||
|
let (name, response) = delegate.call(method, arguments).await;
|
||||||
|
assert_eq!(name, method, "AL-7: {method} refused to the delegate: {response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// AL-6: reading adds nothing, not even at the top of empty Files
|
||||||
|
let (_, response) = delegate
|
||||||
|
.call(
|
||||||
|
"FileNode/set",
|
||||||
|
json!({"accountId": owner_id, "create": {"f": {"name": "Notes", "parentId": null}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(response["created"].get("f").is_none(), "AL-6: a read delegate added a file: {response}");
|
||||||
|
|
||||||
// The delegate reads the mail that arrived
|
// The delegate reads the mail that arrived
|
||||||
let (_, found) = delegate
|
let (_, found) = delegate
|
||||||
.call(
|
.call(
|
||||||
@@ -222,6 +245,27 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
"AL-5: {response}"
|
"AL-5: {response}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// AL-7: organize adds at the top of the locked account's Files, which
|
||||||
|
// held none, and sees what it made
|
||||||
|
let (_, response) = delegate
|
||||||
|
.call(
|
||||||
|
"FileNode/set",
|
||||||
|
json!({"accountId": owner_id, "create": {"f": {"name": "Handover notes", "parentId": null}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let folder_id = response["created"]["f"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("AL-7: organize couldn't add to empty Files: {response}"))
|
||||||
|
.to_string();
|
||||||
|
let (_, response) = delegate
|
||||||
|
.call("FileNode/get", json!({"accountId": owner_id, "ids": [folder_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["list"].as_array().map(Vec::len),
|
||||||
|
Some(1),
|
||||||
|
"AL-7: the delegate can't see the folder it made: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
// Test 12, AL-6, AL-7: organize makes folders it can see, moves mail,
|
// Test 12, AL-6, AL-7: organize makes folders it can see, moves mail,
|
||||||
// never deletes it
|
// never deletes it
|
||||||
let (_, mailboxes) = delegate
|
let (_, mailboxes) = delegate
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Legal holds, the object itself (audit-hold-lock spec, LH-1, LH-3, LH-13,
|
||||||
|
//! AU-12): placing, widening and releasing a hold, and who may. What a hold
|
||||||
|
//! keeps is tested with the undelete hooks.
|
||||||
|
|
||||||
|
use crate::utils::{
|
||||||
|
account::Account,
|
||||||
|
server::{TestServer, TestServerBuilder},
|
||||||
|
};
|
||||||
|
use registry::schema::{
|
||||||
|
prelude::{ObjectType, Property},
|
||||||
|
structs::{
|
||||||
|
CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant,
|
||||||
|
UserRoles,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
const INBOX_ID: u32 = 0;
|
||||||
|
|
||||||
|
const USING: &[&str] = &[
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:ietf:params:jmap:contacts",
|
||||||
|
"urn:inbuxa:jmap",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) {
|
||||||
|
if arguments.get("accountId").is_none() {
|
||||||
|
arguments["accountId"] = self.id_string().into();
|
||||||
|
}
|
||||||
|
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
|
||||||
|
let call = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/0")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
||||||
|
(call[0].as_str().unwrap_or_default().to_string(), call[1].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hold_set(&self, arguments: Value) -> Value {
|
||||||
|
let (name, response) = self.hold_call("inbuxa:LegalHold/set", arguments).await;
|
||||||
|
assert_eq!(name, "inbuxa:LegalHold/set", "{response}");
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn archived_items(&self) -> Vec<Value> {
|
||||||
|
let (_, response) = self
|
||||||
|
.hold_call("x:ArchivedItem/get", json!({"ids": null}))
|
||||||
|
.await;
|
||||||
|
response["list"].as_array().cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hold_get(&self, id: &str) -> Value {
|
||||||
|
let (name, response) = self
|
||||||
|
.hold_call("inbuxa:LegalHold/get", json!({"ids": [id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(name, "inbuxa:LegalHold/get", "{response}");
|
||||||
|
response["list"][0].clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test(test: &mut TestServer) {
|
||||||
|
println!("Running legal hold tests...");
|
||||||
|
let admin = test.account("[email protected]");
|
||||||
|
let custodian = admin
|
||||||
|
.create_user_account("[email protected]", "custodian-secret-2201", "Custodian", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let other = admin
|
||||||
|
.create_user_account("[email protected]", "other-secret-7310", "Other", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let custodian_id = custodian.id_string().to_string();
|
||||||
|
let other_id = other.id_string().to_string();
|
||||||
|
|
||||||
|
// AU-12: no hold without a reason; LH-1: nor without a name or a scope
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"create": {"h": {"name": "Matter 4411",
|
||||||
|
"scope": {"accounts": [custodian_id]}}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "AU-12: {response}");
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||||
|
"scope": {"accounts": [custodian_id]}}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 name: {response}");
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||||
|
"name": "Matter 4411", "scope": {}}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 scope: {response}");
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||||
|
"name": "Matter 4411", "scope": {"accounts": ["zzzzzz"]}}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["h"]["type"], "invalidProperties",
|
||||||
|
"LH-1 unknown account: {response}"
|
||||||
|
);
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||||
|
"name": "Matter 4411",
|
||||||
|
"from": "2026-06-30T00:00:00Z", "to": "2026-01-01T00:00:00Z",
|
||||||
|
"scope": {"accounts": [custodian_id]}}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-3 backwards: {response}");
|
||||||
|
|
||||||
|
// LH-1: placed, with a reference and a range
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"create": {"h": {
|
||||||
|
"name": "Matter 4411", "reference": "4411-A", "reason": "Counsel's letter",
|
||||||
|
"from": "2026-01-01T00:00:00Z", "to": "2026-06-30T23:59:59Z",
|
||||||
|
"scope": {"accounts": [custodian_id]}}}}))
|
||||||
|
.await;
|
||||||
|
let hold_id = response["created"]["h"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("LH-1: not placed: {response}"))
|
||||||
|
.to_string();
|
||||||
|
let hold = admin.hold_get(&hold_id).await;
|
||||||
|
assert_eq!(hold["name"], "Matter 4411", "{hold}");
|
||||||
|
assert_eq!(hold["reference"], "4411-A", "{hold}");
|
||||||
|
assert_eq!(hold["scope"]["accounts"], json!([custodian_id]), "{hold}");
|
||||||
|
assert_eq!(hold["from"], "2026-01-01T00:00:00Z", "{hold}");
|
||||||
|
assert_eq!(hold["released"], false, "{hold}");
|
||||||
|
assert!(hold["placedBy"].as_str().is_some_and(|by| by.contains("admin")), "{hold}");
|
||||||
|
|
||||||
|
// AU-12: every later change needs a reason too
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"update": {hold_id.as_str(): {"name": "Renamed"}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"AU-12: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// LH-3: narrowing is refused, widening is allowed
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Narrow it", "update": {hold_id.as_str(): {
|
||||||
|
"from": "2026-03-01T00:00:00Z"}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"LH-3 narrowed: {response}"
|
||||||
|
);
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Counsel widened the matter", "update": {hold_id.as_str(): {
|
||||||
|
"from": "2025-01-01T00:00:00Z", "to": null}}}))
|
||||||
|
.await;
|
||||||
|
assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-3 widened: {response}");
|
||||||
|
let hold = admin.hold_get(&hold_id).await;
|
||||||
|
assert_eq!(hold["from"], "2025-01-01T00:00:00Z", "{hold}");
|
||||||
|
assert_eq!(hold["to"], Value::Null, "LH-3: an open end catches mail to come: {hold}");
|
||||||
|
|
||||||
|
// The scope grows, and never shrinks
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Second custodian", "update": {hold_id.as_str(): {
|
||||||
|
"scope": {"accounts": [custodian_id, other_id]}}}}))
|
||||||
|
.await;
|
||||||
|
assert!(response["updated"].get(hold_id.as_str()).is_some(), "scope grown: {response}");
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Drop one", "update": {hold_id.as_str(): {
|
||||||
|
"scope": {"accounts": [other_id]}}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"scope shrunk: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// LH-13: a hold is never deleted
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Delete it", "destroy": [hold_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notDestroyed"][hold_id.as_str()]["type"], "forbidden",
|
||||||
|
"LH-13: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// LH-13: only server-level administrators see holds, never a plain user
|
||||||
|
let (name, response) = custodian
|
||||||
|
.hold_call("inbuxa:LegalHold/get", json!({"ids": null}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(name, "error", "LH-13: a user read holds: {response}");
|
||||||
|
|
||||||
|
// ... and never a tenant administrator, whatever its role says: a hold
|
||||||
|
// may concern the tenant's own administrator
|
||||||
|
let tenant = admin
|
||||||
|
.registry_create_object(Tenant {
|
||||||
|
name: "Hold tenant".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "tenant-hold.example.org".to_string(),
|
||||||
|
is_enabled: true,
|
||||||
|
member_tenant_id: Some(tenant),
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dns_management: DnsManagement::Manual,
|
||||||
|
dkim_management: DkimManagement::Manual,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let t_admin = admin
|
||||||
|
.create_user_account(
|
||||||
|
"[email protected]",
|
||||||
|
"tenant-admin-secret-6604",
|
||||||
|
"Tenant admin",
|
||||||
|
&[],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
t_admin.id(),
|
||||||
|
json!({Property::Roles: UserRoles::Admin}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (name, response) = t_admin
|
||||||
|
.hold_call("inbuxa:LegalHold/get", json!({"ids": null}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(name, "error", "LH-13: a tenant administrator read holds: {response}");
|
||||||
|
let (name, response) = t_admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:LegalHold/set",
|
||||||
|
json!({"reason": "Mine", "create": {"h": {"name": "Tenant matter",
|
||||||
|
"scope": {"accounts": [t_admin.id_string()]}}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(name, "error", "LH-13: a tenant administrator placed a hold: {response}");
|
||||||
|
|
||||||
|
// Test 7, LH-2: a hold on a domain reaches an account created there
|
||||||
|
// later, and keeps it by name when it moves to another domain
|
||||||
|
let held_domain = admin
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "held.example.net".to_string(),
|
||||||
|
is_enabled: true,
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dns_management: DnsManagement::Manual,
|
||||||
|
dkim_management: DkimManagement::Manual,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let elsewhere = admin
|
||||||
|
.registry_create_object(Domain {
|
||||||
|
name: "elsewhere.example.net".to_string(),
|
||||||
|
is_enabled: true,
|
||||||
|
certificate_management: CertificateManagement::Manual,
|
||||||
|
dns_management: DnsManagement::Manual,
|
||||||
|
dkim_management: DkimManagement::Manual,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Whole division", "create": {"d": {
|
||||||
|
"name": "Matter 5120", "scope": {"domains": [held_domain.to_string()]}}}}))
|
||||||
|
.await;
|
||||||
|
let domain_hold = response["created"]["d"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("LH-1 domain hold: {response}"))
|
||||||
|
.to_string();
|
||||||
|
let mover = admin
|
||||||
|
.create_user_account("[email protected]", "mover-secret-8812", "Mover", &[], vec![])
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
admin.hold_get(&domain_hold).await["scope"]["accounts"],
|
||||||
|
json!([]),
|
||||||
|
"LH-2: covered through the domain, not named yet"
|
||||||
|
);
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
mover.id(),
|
||||||
|
json!({Property::DomainId: elsewhere.to_string()}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
admin.hold_get(&domain_hold).await["scope"]["accounts"],
|
||||||
|
json!([mover.id_string()]),
|
||||||
|
"test 7, LH-2: the moved account escaped the hold"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 6, LH-4: what a hold keeps, with undelete switched off, so only
|
||||||
|
// the hold can be keeping anything
|
||||||
|
admin
|
||||||
|
.registry_update_setting(
|
||||||
|
DataRetention {
|
||||||
|
archive_deleted_items_for: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&[Property::ArchiveDeletedItemsFor],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let held = admin
|
||||||
|
.create_user_account("[email protected]", "held-secret-4419", "Held", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let ranged = admin
|
||||||
|
.create_user_account("[email protected]", "ranged-secret-5530", "Ranged", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Preserve everything", "create": {
|
||||||
|
"w": {"name": "Matter 6001", "scope": {"accounts": [held.id_string()]}},
|
||||||
|
"r": {"name": "Matter 6002", "from": "2020-01-01T00:00:00Z", "to": "2020-12-31T23:59:59Z",
|
||||||
|
"scope": {"accounts": [ranged.id_string()]}}}}))
|
||||||
|
.await;
|
||||||
|
let whole_hold = response["created"]["w"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("LH-1: {response}"))
|
||||||
|
.to_string();
|
||||||
|
assert!(response["created"]["r"]["id"].is_string(), "LH-1: {response}");
|
||||||
|
|
||||||
|
let held_client = held.jmap_client().await;
|
||||||
|
let ranged_client = ranged.jmap_client().await;
|
||||||
|
let whole = import(&held_client, "Held whole", None).await;
|
||||||
|
held_client.email_destroy(&whole).await.unwrap();
|
||||||
|
// 2020-03-15: inside the range; now: outside it
|
||||||
|
let inside = import(&ranged_client, "Inside the range", Some(1_584_230_400)).await;
|
||||||
|
let outside = import(&ranged_client, "Outside the range", None).await;
|
||||||
|
ranged_client.email_destroy(&inside).await.unwrap();
|
||||||
|
ranged_client.email_destroy(&outside).await.unwrap();
|
||||||
|
|
||||||
|
// LH-3: a contact is held whole, whatever the range
|
||||||
|
let (_, books) = ranged
|
||||||
|
.hold_call("AddressBook/get", json!({"ids": null}))
|
||||||
|
.await;
|
||||||
|
let book = books["list"][0]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("no address book: {books}"))
|
||||||
|
.to_string();
|
||||||
|
{
|
||||||
|
let (_, created) = ranged
|
||||||
|
.hold_call(
|
||||||
|
"ContactCard/set",
|
||||||
|
json!({"create": {"c": {"addressBookIds": {book: true},
|
||||||
|
"name": {"full": "Kept Contact"}}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let card = created["created"]["c"]["id"].as_str().unwrap_or_default().to_string();
|
||||||
|
let (_, destroyed) = ranged
|
||||||
|
.hold_call("ContactCard/set", json!({"destroy": [card]}))
|
||||||
|
.await;
|
||||||
|
assert!(destroyed["destroyed"][0].is_string(), "{destroyed}");
|
||||||
|
}
|
||||||
|
test.wait_for_tasks().await;
|
||||||
|
|
||||||
|
let is_held = |item: &Value| item["archivedUntil"].as_str().is_some_and(|u| u.starts_with("9999-"));
|
||||||
|
let kept = held.archived_items().await;
|
||||||
|
assert!(
|
||||||
|
kept.iter().any(|i| i["subject"] == "Held whole" && is_held(i)),
|
||||||
|
"test 6, LH-4: a held account's mail wasn't kept: {kept:?}"
|
||||||
|
);
|
||||||
|
let kept = ranged.archived_items().await;
|
||||||
|
assert!(
|
||||||
|
kept.iter().any(|i| i["subject"] == "Inside the range" && is_held(i)),
|
||||||
|
"LH-3: mail inside the range wasn't kept: {kept:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!kept.iter().any(|i| i["subject"] == "Outside the range"),
|
||||||
|
"LH-3: mail outside the range was kept, with undelete off: {kept:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kept.iter().any(|i| i["name"] == "Kept Contact" && is_held(i)),
|
||||||
|
"LH-3: a contact wasn't kept whole: {kept:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// LH-6: placing a hold freezes what's already archived; LH-7: frozen
|
||||||
|
// items can't be destroyed; LH-11: releasing one hold of two frees
|
||||||
|
// nothing; LH-10: releasing the last gives a real deadline back
|
||||||
|
admin
|
||||||
|
.registry_update_setting(
|
||||||
|
DataRetention {
|
||||||
|
archive_deleted_items_for: Some(registry::schema::prelude::Duration(
|
||||||
|
std::time::Duration::from_secs(30 * 86_400),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&[Property::ArchiveDeletedItemsFor],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let frozen = admin
|
||||||
|
.create_user_account("[email protected]", "frozen-secret-9031", "Frozen", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let frozen_client = frozen.jmap_client().await;
|
||||||
|
let doomed = import(&frozen_client, "Deleted before the hold", None).await;
|
||||||
|
frozen_client.email_destroy(&doomed).await.unwrap();
|
||||||
|
test.wait_for_tasks().await;
|
||||||
|
let archived = |items: Vec<Value>| {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| i["subject"] == "Deleted before the hold")
|
||||||
|
.unwrap_or_else(|| panic!("not archived"))
|
||||||
|
};
|
||||||
|
let item = archived(frozen.archived_items().await);
|
||||||
|
assert!(!is_held(&item), "undelete's 30 days first: {item}");
|
||||||
|
let item_id = item["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "First matter", "create": {
|
||||||
|
"a": {"name": "Matter 7001", "scope": {"accounts": [frozen.id_string()]}},
|
||||||
|
"b": {"name": "Matter 7002", "scope": {"accounts": [frozen.id_string()]}}}}))
|
||||||
|
.await;
|
||||||
|
let first = response["created"]["a"]["id"].as_str().unwrap().to_string();
|
||||||
|
let second = response["created"]["b"]["id"].as_str().unwrap().to_string();
|
||||||
|
assert!(
|
||||||
|
is_held(&archived(frozen.archived_items().await)),
|
||||||
|
"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}");
|
||||||
|
// LH-14: the holds on one account, for the console's Held badge
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:LegalHold/get",
|
||||||
|
json!({"coveringAccount": frozen.id_string(), "properties": ["name"]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut names = response["list"]
|
||||||
|
.as_array()
|
||||||
|
.map(|l| l.iter().filter_map(|h| h["name"].as_str()).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}");
|
||||||
|
|
||||||
|
// LH-12: collect what the hold keeps, as a ZIP with its manifest
|
||||||
|
import(&frozen_client, "Still in the inbox", None).await;
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"create": {"x": {"holdId": first, "accountIds": [frozen.id_string()]}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["x"]["type"], "invalidProperties",
|
||||||
|
"AU-12: an export without a reason: {response}"
|
||||||
|
);
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"reason": "Production to opposing counsel",
|
||||||
|
"create": {"x": {"holdId": first,
|
||||||
|
"accountIds": [frozen.id_string(), admin.id_string()]}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let export_id = response["created"]["x"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("LH-12: not started: {response}"))
|
||||||
|
.to_string();
|
||||||
|
let mut export = Value::Null;
|
||||||
|
for _ in 0..60 {
|
||||||
|
let (_, got) = admin
|
||||||
|
.hold_call("inbuxa:HoldExport/get", json!({"ids": [export_id]}))
|
||||||
|
.await;
|
||||||
|
export = got["list"][0].clone();
|
||||||
|
if export["status"] != "running" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(export["status"], "ready", "LH-12: {export}");
|
||||||
|
let bytes = admin
|
||||||
|
.jmap_client()
|
||||||
|
.await
|
||||||
|
.download(export["blobId"].as_str().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(export["size"].as_u64(), Some(bytes.len() as u64), "{export}");
|
||||||
|
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||||
|
let names = (0..zip.len())
|
||||||
|
.map(|i| zip.by_index(i).unwrap().name().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.starts_with("[email protected]/mail/") && n.ends_with(".eml")),
|
||||||
|
"LH-12: live mail missing: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.starts_with("[email protected]/archived/email/")),
|
||||||
|
"LH-12: the kept deleted mail is missing: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.")),
|
||||||
|
"LH-12: an account the hold doesn't cover was exported: {names:?}"
|
||||||
|
);
|
||||||
|
let mut manifest = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||||
|
let mut hash = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||||
|
use sha2::Digest;
|
||||||
|
let expected: String = sha2::Sha256::digest(manifest.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect();
|
||||||
|
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
|
||||||
|
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
|
||||||
|
// LH-13: only sysLegalHoldExport starts one
|
||||||
|
let (_, response) = frozen
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"reason": "Mine", "create": {"x": {"holdId": first}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response.to_string().contains("forbidden") && response["created"].is_null(),
|
||||||
|
"LH-13: a user exported a hold: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, response) = frozen
|
||||||
|
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notDestroyed"][item_id.as_str()]["type"], "forbidden",
|
||||||
|
"test 6, LH-7: the owner destroyed a held item: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.to_string().contains("Matter 70"),
|
||||||
|
"LH-7: the hold was named to someone who can't see holds: {response}"
|
||||||
|
);
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"x:ArchivedItem/set",
|
||||||
|
json!({"accountId": frozen.id_string(), "destroy": [item_id]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response.to_string().contains("Matter 7001"),
|
||||||
|
"LH-7: the administrator isn't told which hold: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.hold_set(json!({"reason": "First settled", "update": {first.as_str(): {"released": true}}}))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
is_held(&archived(frozen.archived_items().await)),
|
||||||
|
"test 9, LH-11: releasing one hold of two freed the item"
|
||||||
|
);
|
||||||
|
admin
|
||||||
|
.hold_set(json!({"reason": "Second settled", "update": {second.as_str(): {"released": true}}}))
|
||||||
|
.await;
|
||||||
|
let item = archived(frozen.archived_items().await);
|
||||||
|
assert!(!is_held(&item), "LH-10: the last release left it held: {item}");
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:HoldExport/set",
|
||||||
|
json!({"reason": "Too late", "create": {"x": {"holdId": first}}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notCreated"]["x"]["type"], "invalidProperties",
|
||||||
|
"LH-12: a released hold was exported: {response}"
|
||||||
|
);
|
||||||
|
let until = item["archivedUntil"].as_str().unwrap_or_default().to_string();
|
||||||
|
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||||
|
assert!(
|
||||||
|
until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"test 8, LH-10: under 30 days of grace after release: {until}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 8, LH-8: a held account destroyed as a login is kept, data and
|
||||||
|
// all, with no expiry, although undelete keeps no accounts here
|
||||||
|
let held_id = held.id_string().to_string();
|
||||||
|
admin.destroy_account(held).await;
|
||||||
|
let kept = |list: Value| {
|
||||||
|
list["list"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|l| l.iter().find(|a| a["id"] == held_id.as_str()).cloned())
|
||||||
|
};
|
||||||
|
let (_, list) = admin
|
||||||
|
.hold_call("inbuxa:DeletedAccount/get", json!({"ids": null}))
|
||||||
|
.await;
|
||||||
|
let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-8: not kept: {list}"));
|
||||||
|
assert!(
|
||||||
|
entry["keptUntil"].as_str().is_some_and(|u| u.starts_with("9999-")),
|
||||||
|
"test 8, LH-8: kept with an expiry: {entry}"
|
||||||
|
);
|
||||||
|
let (_, response) = admin
|
||||||
|
.hold_call("inbuxa:DeletedAccount/set", json!({"destroy": [held_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notDestroyed"][held_id.as_str()]["type"], "forbidden",
|
||||||
|
"test 8, LH-8: destroy-now wasn't refused: {response}"
|
||||||
|
);
|
||||||
|
// Its hold names it now, so no domain or tenant move can drop it
|
||||||
|
assert!(
|
||||||
|
admin.hold_get(&whole_hold).await["scope"]["accounts"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|a| a.iter().any(|id| id == held_id.as_str())),
|
||||||
|
"LH-8: the hold doesn't name the deleted account"
|
||||||
|
);
|
||||||
|
// Release: the data is destroyed 30 days later, not before
|
||||||
|
admin
|
||||||
|
.hold_set(json!({"reason": "Matter closed", "update": {whole_hold.as_str(): {"released": true}}}))
|
||||||
|
.await;
|
||||||
|
let (_, list) = admin
|
||||||
|
.hold_call("inbuxa:DeletedAccount/get", json!({"ids": null}))
|
||||||
|
.await;
|
||||||
|
let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-10: gone at release: {list}"));
|
||||||
|
let until = entry["keptUntil"].as_str().unwrap_or_default().to_string();
|
||||||
|
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||||
|
assert!(
|
||||||
|
!until.starts_with("9999-") && until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
"test 8, LH-10: after release, not 30 days of grace: {until}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// LH-10: release needs a reason, and a released hold stays, read-only
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"update": {hold_id.as_str(): {"released": true}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"AU-12 release: {response}"
|
||||||
|
);
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Matter settled", "update": {hold_id.as_str(): {"released": true}}}))
|
||||||
|
.await;
|
||||||
|
assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-10: {response}");
|
||||||
|
let hold = admin.hold_get(&hold_id).await;
|
||||||
|
assert_eq!(hold["released"], true, "{hold}");
|
||||||
|
assert_eq!(hold["releaseReason"], "Matter settled", "{hold}");
|
||||||
|
assert!(hold["releasedAt"].is_string(), "{hold}");
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Rename", "update": {hold_id.as_str(): {"name": "After"}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"LH-1: a released hold changed: {response}"
|
||||||
|
);
|
||||||
|
let response = admin
|
||||||
|
.hold_set(json!({"reason": "Undo", "update": {hold_id.as_str(): {"released": false}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||||
|
"LH-10: a released hold came back: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// AU-12: placing, widening and releasing are recorded with their reasons
|
||||||
|
let (_, query) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"filter": {"targetKind": "inbuxa:LegalHold"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let ids = query["ids"].clone();
|
||||||
|
let (_, records) = admin
|
||||||
|
.hold_call("inbuxa:AuditEvent/get", json!({"ids": ids}))
|
||||||
|
.await;
|
||||||
|
let reasons = records["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap_or_else(|| panic!("AU-12: no records: {records}"))
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r["reason"].as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
||||||
|
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
|
||||||
|
}
|
||||||
|
// AU-1.9: an export is recorded with its reason
|
||||||
|
let (_, query) = admin
|
||||||
|
.hold_call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"filter": {"targetKind": "inbuxa:HoldExport"}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, exports) = admin
|
||||||
|
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
exports["list"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|l| l.iter().any(|r| r["reason"] == "Production to opposing counsel")),
|
||||||
|
"AU-1.9: the export isn't recorded: {exports}"
|
||||||
|
);
|
||||||
|
// A release is recorded under the hold's name, from before to after
|
||||||
|
let list = records["list"].as_array().cloned().unwrap_or_default();
|
||||||
|
let release = list
|
||||||
|
.iter()
|
||||||
|
.find(|r| r["reason"] == "First settled")
|
||||||
|
.unwrap_or_else(|| panic!("the first release isn't recorded: {list:?}"));
|
||||||
|
assert_eq!(release["target"]["name"], "Matter 7001", "{release}");
|
||||||
|
assert!(
|
||||||
|
release["changes"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|c| c.iter().any(|c| c["field"] == "released" && c["before"] == false && c["after"] == true)),
|
||||||
|
"the release doesn't read before/after: {release}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Accounts are named by their full address, not the bare local part
|
||||||
|
let (_, query) = admin
|
||||||
|
.hold_call("inbuxa:AuditEvent/query", json!({"filter": {"targetKind": "x:Account"}}))
|
||||||
|
.await;
|
||||||
|
let (_, accounts) = admin
|
||||||
|
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
accounts["list"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|l| l.iter().any(|r| r["target"]["name"] == "[email protected]")),
|
||||||
|
"an account isn't named by its address: {accounts}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import(
|
||||||
|
client: &jmap_client::client::Client,
|
||||||
|
subject: &str,
|
||||||
|
received_at: Option<i64>,
|
||||||
|
) -> String {
|
||||||
|
client
|
||||||
|
.email_import(
|
||||||
|
format!("From: [email protected]\r\nSubject: {subject}\r\n\r\nBody.\r\n").into_bytes(),
|
||||||
|
[Id::from(INBOX_ID).to_string()],
|
||||||
|
None::<Vec<&str>>,
|
||||||
|
received_at,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.take_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs these tests alone: `cargo test -p tests legal_hold_tests -- --ignored`.
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
pub async fn legal_hold_tests() {
|
||||||
|
let mut test = TestServerBuilder::new("legal_hold_tests")
|
||||||
|
.await
|
||||||
|
.with_default_listeners()
|
||||||
|
.await
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
let admin = test.create_admin_account("[email protected]").await;
|
||||||
|
test.insert_account(admin);
|
||||||
|
self::test(&mut test).await;
|
||||||
|
if test.is_reset() {
|
||||||
|
test.temp_dir.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod ai;
|
|||||||
pub mod ai_calibration;
|
pub mod ai_calibration;
|
||||||
pub mod ai_explain;
|
pub mod ai_explain;
|
||||||
pub mod account_lock; // inbuxa: account lock with delegation
|
pub mod account_lock; // inbuxa: account lock with delegation
|
||||||
|
pub mod legal_hold; // inbuxa: legal hold
|
||||||
pub mod audit; // inbuxa: the audit log
|
pub mod audit; // inbuxa: the audit log
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||||
|
|||||||
Reference in New Issue
Block a user