Add the compliance permission and the Compliance Officer roles #88
@@ -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
|
||||
|
||||
@@ -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<Id>) -> 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<Id>) -> 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<Id>) -> trc::Result<Option<Id>> {
|
||||
Ok(data
|
||||
.get_value::<u64>(ValueKey::from(created_key(tenant)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(Id::from))
|
||||
}
|
||||
|
||||
async fn record(data: &Store, tenant: Option<Id>, role: Option<Id>) -> 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<Id>,
|
||||
role: Role,
|
||||
) -> trc::Result<Option<Id>> {
|
||||
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::<Tenant>().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<bool> {
|
||||
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<Permission> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,6 +1746,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,
|
||||
|
||||
@@ -7090,6 +7090,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,
|
||||
@@ -7779,6 +7780,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",
|
||||
@@ -8461,6 +8463,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),
|
||||
@@ -8905,7 +8908,7 @@ impl EnumImpl for Permission {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 673;
|
||||
const COUNT: usize = 674;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Permission {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ZjiCQWnwHbujcF7hNolPZQa-VtrNTNfc_HbSuZZ_TiY
|
||||
8Wyl9buv_eSbbGGhV-UZcjGHG_H77eorpuoI2u6wgnY
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user