Legal holds (phase 3) #70
@@ -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())
|
||||||
|
|||||||
@@ -574,6 +574,33 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
||||||
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
|
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
|
||||||
}
|
}
|
||||||
|
// 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(
|
async fn import(
|
||||||
|
|||||||
Reference in New Issue
Block a user