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 862bb88..793e9ed 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 083abdc..98d8d7a 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -MiWRzsz0AHdRshG_8JimktRqBO_pHGAUBHFcfV5jwI4 \ No newline at end of file +nh0Vx79fhlQAOjCC9it831hBytCjhGPloPm9qidUhGc \ No newline at end of file diff --git a/tests/src/system/compliance.rs b/tests/src/system/compliance.rs new file mode 100644 index 0000000..4f764a2 --- /dev/null +++ b/tests/src/system/compliance.rs @@ -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) -> 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