Audit records name accounts in full, and holds by name
ci / fork-checks (pull_request) Successful in 1m17s
ci / build (pull_request) Successful in 6m24s

An account's or mailing list's name is only its local part, so the log
said "Account ken.gosling" where two domains could each have one; it
now says [email protected]. A change to a legal hold was
recorded under its id; the hold's current state is now read first, so
the record carries its case name and each change reads before/after.
This commit is contained in:
2026-09-27 19:33:07 -07:00
parent 3217aae4e8
commit c1b5bf956c
2 changed files with 88 additions and 3 deletions
+61 -3
View File
@@ -167,7 +167,8 @@ async fn before<T: JmapObject>(
for (client_id, value) in request.create.iter().flat_map(|c| c.iter()) {
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
.as_object()
.map(|patch| diff::patch(object, None, patch))
@@ -192,7 +193,10 @@ async fn before<T: JmapObject>(
None => fork_current(server, object, id).await,
};
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
.as_object()
.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 {
for id in destroy {
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((
Item::Destroy(id.clone()),
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
/// recorded with what they replaced. Their stored names are the JMAP
/// 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> {
use inbuxa_features::{ai::limits, audit::log, security};
let data = server.store();
@@ -361,6 +391,34 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
.await
.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 {
MaybeInvalid::Value(id) => {
security::tenant_protocol_policy::get(data, id.document_id())
+27
View File
@@ -574,6 +574,33 @@ pub async fn test(test: &mut TestServer) {
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
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(