From 63adb4e2b88a4583d80ff2c4a25a4dd13dd43c24 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 08:50:17 -0700 Subject: [PATCH] Add the compliance permission and the Compliance Officer roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/common/src/auth/permissions.rs | 6 + crates/common/src/manager/compliance_roles.rs | 267 ++++++++++++++++++ crates/common/src/manager/defaults.rs | 2 + .../common/src/manager/granted_permissions.rs | 9 +- crates/common/src/manager/mod.rs | 1 + crates/jmap/src/registry/set.rs | 28 ++ crates/registry/src/schema/enums.rs | 2 + crates/registry/src/schema/enums_impl.rs | 5 +- docs/spec/features/personal-data-catalog.md | 11 + resources/schema/schema.json.gz | Bin 151264 -> 151300 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/compliance.rs | 219 ++++++++++++++ tests/src/system/mod.rs | 1 + 13 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 crates/common/src/manager/compliance_roles.rs create mode 100644 tests/src/system/compliance.rs diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 6b6400b..8fac42f 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -290,6 +290,12 @@ impl Default for DefaultPermissions { default.superuser.push(permission); default.tenant.push(permission); } + // inbuxa: personal-data catalog: the data inventory, the + // server's or, inside a tenant, the tenant's slice + Permission::SysComplianceGet => { + default.superuser.push(permission); + default.tenant.push(permission); + } // inbuxa: AL-12: tenant administrators lock and delegate // within their tenant Permission::SysAccountLockGet diff --git a/crates/common/src/manager/compliance_roles.rs b/crates/common/src/manager/compliance_roles.rs new file mode 100644 index 0000000..e9e2733 --- /dev/null +++ b/crates/common/src/manager/compliance_roles.rs @@ -0,0 +1,267 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The compliance roles (personal-data catalog spec, §7; settled +//! 2026-09-28): a server-level Compliance Officer, and one Compliance +//! Officer role in each tenant. A tenant's accounts can hold only roles of +//! their own tenant (MT-3), so the tenant role is made per tenant: once for +//! each tenant a server already has, and whenever a tenant is created. +//! +//! Each creation is recorded under `P` `c` in the fork's subspace, so a +//! role an administrator deletes stays deleted. A tenant's role, while +//! nobody holds it, is removed with the tenant so it doesn't block the +//! delete. +//! +//! Both read what compliance work needs and change no server setting. The +//! server-level officer also places, widens, releases and exports legal +//! holds: that is the job, and each is audited with its reason. A tenant's +//! role has no holds, which are server-level only (LH-13), and the tenant +//! ceiling keeps it within the tenant. Each role carries a user's own +//! permissions too (signing in, mail), since roles given to a person replace +//! the default user role, and a tenant's accounts can't hold the +//! server-level User role. + +use registry::schema::{ + enums::Permission, + prelude::ObjectType, + structs::{Role, Tenant}, +}; +use registry::types::map::Map; +use store::{ + RegistryStore, SUBSPACE_INBUXA, Store, ValueKey, + registry::write::{RegistryWrite, RegistryWriteResult}, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; +use types::id::Id; + +/// The role's name, in the server's roles and in each tenant's. +pub const NAME: &str = "Compliance Officer"; + +/// Reading who and what records refer to, for both roles. +const READS: &[Permission] = &[ + Permission::SysAccountGet, + Permission::SysAccountQuery, + Permission::SysMailingListGet, + Permission::SysMailingListQuery, + Permission::SysDomainGet, + Permission::SysDomainQuery, + Permission::SysTenantGet, + Permission::SysTenantQuery, + Permission::SysRoleGet, + Permission::SysRoleQuery, +]; + +/// What the server-level officer holds besides [`READS`]. +const OFFICER: &[Permission] = &[ + Permission::SysComplianceGet, + Permission::SysAuditGet, + Permission::SysAuditExport, + Permission::SysLegalHoldGet, + Permission::SysLegalHoldCreate, + Permission::SysLegalHoldUpdate, + Permission::SysLegalHoldExport, + Permission::SysAccountLockGet, +]; + +/// What a tenant's officer holds besides [`READS`]. +const TENANT_OFFICER: &[Permission] = &[ + Permission::SysComplianceGet, + Permission::SysAuditGet, + Permission::SysAuditExport, + Permission::SysAccountLockGet, +]; + +fn role(own: &[Permission], tenant: Option) -> Role { + let mut permissions = crate::auth::permissions::DefaultPermissions::default().user; + for permission in own.iter().chain(READS) { + if !permissions.contains(permission) { + permissions.push(*permission); + } + } + Role { + description: NAME.into(), + enabled_permissions: Map::new(permissions), + member_tenant_id: tenant, + ..Default::default() + } +} + +/// The server-level Compliance Officer role. +pub fn officer_role() -> Role { + role(OFFICER, None) +} + +/// A tenant's Compliance Officer role. +pub fn tenant_role(tenant: Id) -> Role { + role(TENANT_OFFICER, Some(tenant)) +} + +/// Where a creation is recorded: the server's role, or a tenant's. The value +/// is the role's id. +fn created_key(tenant: Option) -> ValueClass { + let mut key = b"Pc".to_vec(); + if let Some(tenant) = tenant { + key.extend_from_slice(&tenant.id().to_be_bytes()); + } + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +async fn recorded(data: &Store, tenant: Option) -> trc::Result> { + Ok(data + .get_value::(ValueKey::from(created_key(tenant))) + .await + .caused_by(trc::location!())? + .map(Id::from)) +} + +async fn record(data: &Store, tenant: Option, role: Option) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + match role { + Some(role) => batch.set(created_key(tenant), role.id().to_be_bytes().to_vec()), + None => batch.clear(created_key(tenant)), + }; + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// Creates a role, unless one was created for this place before, and records +/// it. Returns the new role's id. +async fn create_once( + registry: &RegistryStore, + data: &Store, + tenant: Option, + role: Role, +) -> trc::Result> { + if recorded(data, tenant).await?.is_some() { + return Ok(None); + } + match registry.write(RegistryWrite::insert(&role.into())).await? { + RegistryWriteResult::Success(id) => { + record(data, tenant, Some(id)).await?; + Ok(Some(id)) + } + err => { + trc::error!( + trc::EventType::Registry(trc::RegistryEvent::ValidationError) + .into_err() + .details(format!("Failed to create the {NAME} role: {err}")) + ); + Ok(None) + } + } +} + +/// Once per server: the officer role, and one in each tenant it already has. +pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) -> trc::Result<()> { + create_once(registry, data, None, officer_role()).await?; + for tenant in registry.list::().await? { + let tenant = Id::from(tenant.id.id()); + create_once(registry, data, Some(tenant), tenant_role(tenant)).await?; + } + Ok(()) +} + +/// A new tenant gets its Compliance Officer role. +pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> { + create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ()) +} + +/// Before a tenant is deleted: removes its Compliance Officer role if nobody +/// holds it, so the role doesn't block the delete. Returns whether it did, +/// so a delete refused for another reason can put it back. +pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result { + let Some(role) = recorded(data, Some(tenant)).await? else { + return Ok(false); + }; + match registry + .write(RegistryWrite::delete(ObjectType::Role.id(role))) + .await? + { + RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => { + record(data, Some(tenant), None).await?; + Ok(true) + } + // Held by someone: the tenant's delete is refused for that anyway + _ => Ok(false), + } +} + +/// A tenant's delete was refused after its role went: the role comes back. +pub async fn tenant_kept(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> { + tenant_created(registry, data, tenant).await +} + +#[cfg(test)] +mod tests { + use super::*; + use registry::types::EnumImpl; + + fn permissions(role: &Role) -> Vec { + role.enabled_permissions.iter().copied().collect() + } + + #[test] + fn neither_role_changes_a_setting() { + let user = crate::auth::permissions::DefaultPermissions::default().user; + for role in [officer_role(), tenant_role(Id::from(7u64))] { + let all = permissions(&role); + for permission in user.iter() { + assert!(all.contains(permission), "a user's own {permission:?}"); + } + // Beyond what any user holds for their own account + for permission in all.into_iter().filter(|p| !user.contains(p)) { + let name = permission.as_str(); + let holds = name.starts_with("sysLegalHold"); + assert!( + !(name.ends_with("Update") && !holds) + && !(name.ends_with("Create") && !holds) + && !name.ends_with("Destroy") + && permission != Permission::Impersonate + && permission != Permission::FetchAnyBlob, + "{} holds {name}", + role.description + ); + } + } + } + + #[test] + fn the_officer_places_and_releases_holds_a_tenants_does_not() { + let officer = permissions(&officer_role()); + let tenant = tenant_role(Id::from(7u64)); + assert_eq!(tenant.member_tenant_id, Some(Id::from(7u64))); + let tenant = permissions(&tenant); + for hold in [ + Permission::SysLegalHoldGet, + Permission::SysLegalHoldCreate, + Permission::SysLegalHoldUpdate, + Permission::SysLegalHoldExport, + ] { + assert!(officer.contains(&hold)); + assert!(!tenant.contains(&hold)); + } + for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] { + assert!(officer.contains(&both) && tenant.contains(&both)); + } + assert!(!officer.contains(&Permission::SysAuditSettingsUpdate)); + } + + #[test] + fn records_are_per_place() { + let ValueClass::Any(server) = created_key(None) else { panic!() }; + let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() }; + let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() }; + assert_eq!(server.key, b"Pc"); + assert_ne!(a.key, b.key); + assert!(a.key.starts_with(b"Pc")); + } +} diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index 5f759b4..434ff29 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -482,6 +482,8 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { // inbuxa: administrator roles stored before a permission existed get it once super::granted_permissions::grant_new_admin_permissions(bp).await?; + // inbuxa: personal-data catalog: the compliance roles, once per server + super::compliance_roles::ensure_compliance_roles(&bp.registry, &bp.data_store).await?; if bp .registry diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index f0220cc..3c7d996 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -30,7 +30,8 @@ use types::id::Id; /// Granted to the default administrator roles: "Explain this" /// (ai-explain spec, EX-4: superuser by default), the audit log, account -/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13). +/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and +/// the data inventory (personal-data catalog spec). const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAiExplain, Permission::SysAuditGet, @@ -44,11 +45,12 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysLegalHoldCreate, Permission::SysLegalHoldUpdate, Permission::SysLegalHoldExport, + Permission::SysComplianceGet, ]; /// Granted to the default tenant administrator roles: reading and exporting -/// the tenant's audit log (AU-9), and locking and delegating its accounts -/// (AL-12). +/// the tenant's audit log (AU-9), locking and delegating its accounts +/// (AL-12), and the tenant's slice of the data inventory. const TENANT_GRANTS: &[Permission] = &[ Permission::SysAuditGet, Permission::SysAuditExport, @@ -56,6 +58,7 @@ const TENANT_GRANTS: &[Permission] = &[ Permission::SysAccountLockCreate, Permission::SysAccountLockUpdate, Permission::SysAccountLockDestroy, + Permission::SysComplianceGet, ]; #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/common/src/manager/mod.rs b/crates/common/src/manager/mod.rs index a8201ec..9462c12 100644 --- a/crates/common/src/manager/mod.rs +++ b/crates/common/src/manager/mod.rs @@ -18,6 +18,7 @@ use utils::HttpLimitResponse; pub mod application; pub mod backup; pub mod boot; +pub mod compliance_roles; // inbuxa: personal-data catalog, the compliance roles pub mod console; pub mod defaults; pub mod first_party; diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 0eda5fd..9365725 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -747,6 +747,16 @@ impl RegistrySet for Server { if let ObjectInner::MaskedEmail(mask) = &new_object.inner { crate::inbuxa::masked_email::created(self, id, mask).await?; } + // inbuxa: personal-data catalog: a new tenant gets its + // Compliance Officer role + if matches!(new_object.inner, ObjectInner::Tenant(_)) { + common::manager::compliance_roles::tenant_created( + self.registry(), + &self.core.storage.data, + id, + ) + .await?; + } response.object.insert(Property::Id, RegistryValue::Id(id)); set.response .created @@ -800,6 +810,15 @@ impl RegistrySet for Server { && object.inner.account_id() != Some(Id::from(set.account_id)))) }) { + // inbuxa: personal-data catalog: a tenant's compliance + // role, while nobody holds it, goes first + let role_released = matches!(object.inner, ObjectInner::Tenant(_)) + && common::manager::compliance_roles::tenant_deleting( + self.registry(), + &self.core.storage.data, + id, + ) + .await?; match self .registry() .write(RegistryWrite::Delete { @@ -863,6 +882,15 @@ impl RegistrySet for Server { set.response.destroyed.push(id); } err => { + // inbuxa: refused for another reason: the role comes back + if role_released { + common::manager::compliance_roles::tenant_kept( + self.registry(), + &self.core.storage.data, + id, + ) + .await?; + } set.response.not_destroyed.append(id, map_write_error(err)); } } diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index b9ec2f5..f54ee46 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1744,6 +1744,8 @@ pub enum Permission { SysLegalHoldCreate = 670, SysLegalHoldUpdate = 671, SysLegalHoldExport = 672, + // inbuxa: personal-data catalog, the data inventory and compliance overview + SysComplianceGet = 673, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 312b179..587b458 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7084,6 +7084,7 @@ impl EnumImpl for Permission { b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate, b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate, b"sysLegalHoldExport" => Permission::SysLegalHoldExport, + b"sysComplianceGet" => Permission::SysComplianceGet, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7773,6 +7774,7 @@ impl EnumImpl for Permission { Permission::SysLegalHoldCreate => "sysLegalHoldCreate", Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate", Permission::SysLegalHoldExport => "sysLegalHoldExport", + Permission::SysComplianceGet => "sysComplianceGet", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8455,6 +8457,7 @@ impl EnumImpl for Permission { 670 => Some(Permission::SysLegalHoldCreate), 671 => Some(Permission::SysLegalHoldUpdate), 672 => Some(Permission::SysLegalHoldExport), + 673 => Some(Permission::SysComplianceGet), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8899,7 +8902,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 673; + const COUNT: usize = 674; } impl serde::Serialize for Permission { diff --git a/docs/spec/features/personal-data-catalog.md b/docs/spec/features/personal-data-catalog.md index 6229d8b..29a3c06 100644 --- a/docs/spec/features/personal-data-catalog.md +++ b/docs/spec/features/personal-data-catalog.md @@ -526,6 +526,17 @@ it (Settled 3). It has no hold permissions, since legal holds are server-only by the tenant ceiling (LH-13), and it sees the tenant's slice of the inventory only (§6). +**As built (2026-09-28).** A tenant's accounts can hold only roles of +their own tenant (MT-3), so the tenant role can't be one server-level role: +each tenant gets its own "Compliance Officer" role, 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. For the +same reason (a tenant's accounts can't hold the server-level User role), +both roles carry a user's own permissions as well, and are given in place +of the default user role. Creations are recorded under `P` `c`, so a role +an administrator deletes stays deleted +(`crates/common/src/manager/compliance_roles.rs`). + ### Reaching existing servers New permissions get ids 673 onward and `COUNT` grows (`enums.rs`, diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 862bb886a355c4bbb4adbbd52623f37a3866202f..793e9ed5ebb0429e9c395c53146a8abe6a78e9ae 100644 GIT binary patch delta 19794 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 delta 19758 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 (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) -> Vec { + 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::(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("admin@example.com"); + + // 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("officer@example.com", "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( + "officer@tenant-compliance.example.org", + "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("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 7717d50..e0d212e 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -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 -- 2.54.0