Add the compliance permission and the Compliance Officer roles
ci / fork-checks (pull_request) Successful in 31s
ci / build (pull_request) Successful in 11m31s

Personal-data catalog spec, §7 (settled 2026-09-28).

sysComplianceGet (673) sees the data inventory and compliance
overview: superusers and, for their tenant's slice, tenant
administrators, by default and through the one-time grants on servers
that already have their roles stored.

A Compliance Officer role at server level holds it with reading and
exporting the audit log, placing, widening, releasing and exporting
legal holds, seeing account locks, and reading accounts, lists,
domains, tenants and roles. It changes no server setting, creates or
deletes no account, and can't shorten audit retention.

A tenant's accounts can hold only roles of their own tenant (MT-3), so
the tenant role is one "Compliance Officer" role per tenant, without
holds (LH-13): made once for every tenant a server has, and whenever a
tenant is created. While nobody holds it, it is removed with its tenant
so it doesn't block the delete, and put back if the delete is refused
for another reason. Both roles carry a user's own permissions too,
since roles given to a person replace the default user role, which a
tenant's accounts can't hold anyway.

Every server makes these once, new or existing -- the built-in roles
are only made on a server with none -- and records each under P c, so a
role an administrator deletes stays deleted.

Tested: unit tests (neither role changes a setting beyond a user's
own; holds for the server's officer only; per-place records); a new
compliance system test (one server-level role; an officer reads the
audit log, places and releases a hold, and is refused a setting, an
account and audit retention; a tenant gets its role, whose holder reads
the tenant's audit log and no holds; a tenant with an unused role is
deleted and the role goes with it); the system, audit, legal hold,
account lock and SCIM suites; fork checks. The directory suite needs
its LDAP container and wasn't run here.
This commit is contained in:
2026-09-28 08:50:17 -07:00
parent d107c1b2bb
commit 63adb4e2b8
13 changed files with 548 additions and 5 deletions
+219
View File
@@ -0,0 +1,219 @@
/*
* 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}");
// 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();
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod ai_calibration;
pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation
pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles
pub mod audit; // inbuxa: the audit log
pub mod authorization;
pub mod auto_reload; // inbuxa: registry writes apply at once