Files
inbuxa-server/tests/src/system/compliance.rs
T
jcoffey-dev b20b09f81a
ci / fork-checks (pull_request) Successful in 1m3s
ci / build (pull_request) Successful in 1h11m16s
New installs start with the hashed-address blocklist off, and DNSBL zones read right
Personal-data catalog spec, default D5 (settled 2026-09-28; built after
the v0.16.24 import's spam-rules loader landed). msbl.org's EBL is sent
a SHA-1 of every email address it's asked about. A new install's first
boot now leaves a note, and the rules update, once the bundled rules
are in, switches STWT_MSBL_EBL_EMAIL off and forgets the note, so it
happens once; the loader keeps that switch through later updates. An
existing server has no note and keeps every blocklist as it is.

Also fixes the data inventory's DNSBL endpoints: a zone is an
expression (`ip_reverse + '.zen.spamhaus.org'`, conditional branches,
`hash(email, 'sha1') + '.ebl.msbl.org'`), and the zone names are now
the quoted literals that start with a dot, from every branch, rather
than the expression's text.

Tested: unit test for the zone rule; the compliance system test (no
note, no change; the inventory lists ebl.msbl.org, not a hash; with the
note the blocklist goes off; the note works once); the system suite;
fork checks.
2026-09-28 10:21:15 -07:00

348 lines
13 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The compliance roles (personal-data catalog spec, §7): each made once,
//! the officer reads the audit log and places holds but changes no setting,
//! and the tenant officer reaches no holds.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{
CertificateManagement, CustomRoles, DkimManagement, DnsManagement, Domain, Role, Tenant,
UserRoles,
},
};
use registry::types::map::Map;
use serde_json::{Value, json};
use types::id::Id;
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:inbuxa:jmap",
"urn:inbuxa:jmap:registry",
];
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
if arguments.get("accountId").is_none() {
arguments["accountId"] = account.id_string().into();
}
let response = account.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())
}
/// The ids of the roles with this name and tenant, as an administrator sees them.
async fn roles_named(admin: &Account, description: &str, tenant: Option<Id>) -> Vec<Id> {
let mut found = Vec::new();
for id in admin
.registry_query_ids(ObjectType::Role, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
.await
{
let role = admin.registry_get::<Role>(id).await;
if role.description == description && role.member_tenant_id == tenant {
found.push(id);
}
}
found
}
pub async fn test(test: &mut TestServer) {
println!("Running compliance role tests...");
let admin = test.account("[email protected]");
// The server's officer role exists, once
let officer_role = roles_named(&admin, "Compliance Officer", None).await;
let user_role = roles_named(&admin, "User", None).await;
assert_eq!(officer_role.len(), 1, "one server-level Compliance Officer role");
assert_eq!(user_role.len(), 1);
// A compliance officer: the role carries a user's own permissions too
let officer = admin
.create_user_account("[email protected]", "officer-secret-4410", "Officer", &[], vec![])
.await;
admin
.registry_update_object(
ObjectType::Account,
officer.id(),
json!({Property::Roles: UserRoles::Custom(CustomRoles {
role_ids: Map::new(vec![officer_role[0]]),
})}),
)
.await;
// Reads and exports the audit log
let (name, response) = call(&officer, "inbuxa:AuditEvent/query", json!({})).await;
assert_eq!(name, "inbuxa:AuditEvent/query", "the officer reads the audit log: {response}");
// Places and releases a hold: that is the role
let (name, response) = call(
&officer,
"inbuxa:LegalHold/set",
json!({"reason": "Regulator's request", "create": {"h": {"name": "Matter 9001",
"scope": {"accounts": [officer.id_string()]}}}}),
)
.await;
let hold = response["created"]["h"]["id"]
.as_str()
.unwrap_or_else(|| panic!("the officer places a hold: {name} {response}"))
.to_string();
let (_, response) = call(
&officer,
"inbuxa:LegalHold/set",
json!({"reason": "Closed", "update": {hold.as_str(): {"released": true}}}),
)
.await;
assert!(
response["updated"].get(hold.as_str()).is_some(),
"the officer releases a hold: {response}"
);
// Changes no server setting and creates no account
let (name, response) = call(
&officer,
"x:DataRetention/set",
json!({"update": {"singleton": {"holdTracesFor": 86400000}}}),
)
.await;
assert!(
name == "error" || response["notUpdated"].get("singleton").is_some(),
"the officer changed a setting: {name} {response}"
);
let (name, response) = call(
&officer,
"x:Account/set",
json!({"create": {"a": {"@type": "User", "name": "nobody"}}}),
)
.await;
assert!(
name == "error" || response["notCreated"].get("a").is_some(),
"the officer created an account: {name} {response}"
);
let (name, response) = call(
&officer,
"inbuxa:AuditSettings/set",
json!({"update": {"singleton": {"keepForDays": 90}}}),
)
.await;
assert!(
name == "error" || response["notUpdated"].get("singleton").is_some(),
"the officer shortened audit retention: {name} {response}"
);
// A tenant compliance officer reads its tenant's audit log, and no holds
let tenant = admin
.registry_create_object(Tenant {
name: "compliance-tenant".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(Domain {
name: "tenant-compliance.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;
// A new tenant gets its own Compliance Officer role (MT-3: a tenant's
// accounts hold only its own roles)
let tenant_role = roles_named(&admin, "Compliance Officer", Some(tenant)).await;
assert_eq!(tenant_role.len(), 1, "the tenant's Compliance Officer role");
let t_officer = admin
.create_user_account(
"[email protected]",
"tenant-officer-secret-7715",
"Tenant officer",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
t_officer.id(),
json!({Property::Roles: UserRoles::Custom(CustomRoles {
role_ids: Map::new(vec![tenant_role[0]]),
})}),
)
.await;
let (name, response) = call(&t_officer, "inbuxa:AuditEvent/query", json!({})).await;
assert_eq!(name, "inbuxa:AuditEvent/query", "the tenant officer reads the audit log: {response}");
let (name, response) = call(&t_officer, "inbuxa:LegalHold/get", json!({"ids": null})).await;
assert_eq!(name, "error", "LH-13: the tenant officer read holds: {response}");
// The data inventory (§6): the officer reads it, facts not verdicts
let (name, response) = call(&officer, "inbuxa:DataInventory/get", json!({"ids": null})).await;
assert_eq!(name, "inbuxa:DataInventory/get", "{response}");
let inventory = response["list"][0].clone();
let ids: Vec<&str> = inventory["items"]
.as_array()
.unwrap()
.iter()
.filter_map(|i| i["id"].as_str())
.collect();
assert!(ids.contains(&"x:UserAccount") && ids.contains(&"log-file"), "{ids:?}");
assert!(inventory["summary"]["collected"].as_u64().unwrap_or(0) > 0);
assert!(!inventory.to_string().to_lowercase().contains("complian"), "facts only");
// Somebody without the permission is refused
let plain = admin
.create_user_account("[email protected]", "plain-secret-1182", "Plain", &[], vec![])
.await;
let (name, _) = call(&plain, "inbuxa:DataInventory/get", json!({"ids": null})).await;
assert_eq!(name, "error", "a user without sysComplianceGet read the inventory");
// A tenant's officer sees the tenant's slice, and no processors
let (_, response) = call(&t_officer, "inbuxa:DataInventory/get", json!({"ids": null})).await;
let slice = &response["list"][0];
assert!(
slice["items"].as_array().is_some_and(|items| !items.is_empty()
&& items.iter().all(|i| i["scope"] == "tenant")),
"{slice}"
);
assert_eq!(slice["processors"], json!([]));
// A webhook to another host makes it a candidate processor, and the
// change is in the inventory's history
let (_, response) = call(
&admin,
"x:WebHook/set",
json!({"create": {"w": {"url": "https://hooks.example.net/in", "enable": true}}}),
)
.await;
assert!(response["created"].get("w").is_some(), "{response}");
let (_, response) = call(&officer, "inbuxa:DataInventory/get", json!({"ids": null})).await;
let inventory = &response["list"][0];
assert!(
inventory["processors"]
.as_array()
.is_some_and(|p| p.iter().any(|p| p["host"] == "hooks.example.net")),
"{inventory}"
);
let webhooks = inventory["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["id"] == "webhooks")
.unwrap();
assert_eq!(webhooks["collected"], json!(true));
assert_eq!(webhooks["leavesHost"], json!(true));
let (_, response) = call(
&officer,
"inbuxa:InventorySnapshot/get",
json!({"ids": null, "properties": ["id", "takenAt", "trigger", "summary"]}),
)
.await;
let snapshots = response["list"].as_array().cloned().unwrap_or_default();
assert!(
snapshots
.iter()
.any(|s| s["trigger"]["kind"] == "settingChanged" && s["trigger"]["setting"] == "x:WebHook"),
"the webhook's snapshot: {snapshots:?}"
);
assert!(snapshots.iter().all(|s| s.get("inventory").is_none()), "left out when not asked");
// A retention change reads through
let (_, response) = call(
&admin,
"x:DataRetention/set",
json!({"update": {"singleton": {"holdTracesFor": 604800000}}}),
)
.await;
assert!(response["updated"].get("singleton").is_some(), "{response}");
let (_, response) = call(&officer, "inbuxa:DataInventory/get", json!({"ids": null})).await;
let trace = response["list"][0]["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["id"] == "x:Trace")
.cloned()
.unwrap();
assert_eq!(trace["retention"]["days"], json!(7), "{trace}");
// D5: a new install's first rules leave the hashed-address blocklist
// off, once; an existing server (no note) keeps it as it is
let (_, response) = call(
&admin,
"x:SpamDnsblServer/set",
json!({"create": {"m": {"@type": "Email", "name": "STWT_MSBL_EBL_EMAIL", "enable": true,
"zone": {"else": "hash(email, 'sha1') + '.ebl.msbl.org'", "match": {}},
"tag": {"else": "'MSBL_EBL'", "match": {}}}}}),
)
.await;
let msbl = response["created"]["m"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
let registry = test.server.registry();
let store = test.server.store();
assert!(
!common::manager::spam_rules::apply_new_install(registry, store).await.unwrap(),
"no note, no change"
);
let enabled = |response: &Value| response["list"][0]["enable"].clone();
let (_, response) = call(&admin, "x:SpamDnsblServer/get", json!({"ids": [msbl]})).await;
assert_eq!(enabled(&response), json!(true));
let (_, response) = call(&officer, "inbuxa:DataInventory/get", json!({"ids": null})).await;
assert!(
response["list"][0]["processors"]
.as_array()
.is_some_and(|p| p.iter().any(|p| p["host"] == "ebl.msbl.org")),
"the zone, not the hash: {response}"
);
common::manager::spam_rules::mark_new_install(store).await.unwrap();
assert!(common::manager::spam_rules::apply_new_install(registry, store).await.unwrap());
let (_, response) = call(&admin, "x:SpamDnsblServer/get", json!({"ids": [msbl]})).await;
assert_eq!(enabled(&response), json!(false), "{response}");
assert!(
!common::manager::spam_rules::apply_new_install(registry, store).await.unwrap(),
"the note works once"
);
// A tenant can still be deleted: its unused role goes with it
let spare = admin
.registry_create_object(Tenant {
name: "spare-tenant".to_string(),
..Default::default()
})
.await;
assert_eq!(roles_named(&admin, "Compliance Officer", Some(spare)).await.len(), 1);
let (name, response) = call(&admin, "x:Tenant/set", json!({"destroy": [spare.to_string()]})).await;
assert!(
response["destroyed"].as_array().is_some_and(|d| d.iter().any(|i| i == &json!(spare.to_string()))),
"the tenant was deleted: {name} {response}"
);
assert!(roles_named(&admin, "Compliance Officer", Some(spare)).await.is_empty());
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn compliance_tests() {
let mut test = TestServerBuilder::new("compliance_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();
}
}