Add the compliance permission and the Compliance Officer roles #88
@@ -290,6 +290,12 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.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
|
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||||
// within their tenant
|
// within their tenant
|
||||||
Permission::SysAccountLockGet
|
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
|
// inbuxa: administrator roles stored before a permission existed get it once
|
||||||
super::granted_permissions::grant_new_admin_permissions(bp).await?;
|
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
|
if bp
|
||||||
.registry
|
.registry
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ use types::id::Id;
|
|||||||
|
|
||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (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] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -44,11 +45,12 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysLegalHoldCreate,
|
Permission::SysLegalHoldCreate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
|
Permission::SysComplianceGet,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the default tenant administrator roles: reading and exporting
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9), and locking and delegating its accounts
|
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
||||||
/// (AL-12).
|
/// (AL-12), and the tenant's slice of the data inventory.
|
||||||
const TENANT_GRANTS: &[Permission] = &[
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
@@ -56,6 +58,7 @@ const TENANT_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockCreate,
|
Permission::SysAccountLockCreate,
|
||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
|
Permission::SysComplianceGet,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use utils::HttpLimitResponse;
|
|||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod boot;
|
pub mod boot;
|
||||||
|
pub mod compliance_roles; // inbuxa: personal-data catalog, the compliance roles
|
||||||
pub mod console;
|
pub mod console;
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
pub mod first_party;
|
pub mod first_party;
|
||||||
|
|||||||
@@ -747,6 +747,16 @@ impl RegistrySet for Server {
|
|||||||
if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
|
if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
|
||||||
crate::inbuxa::masked_email::created(self, id, mask).await?;
|
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));
|
response.object.insert(Property::Id, RegistryValue::Id(id));
|
||||||
set.response
|
set.response
|
||||||
.created
|
.created
|
||||||
@@ -800,6 +810,15 @@ impl RegistrySet for Server {
|
|||||||
&& object.inner.account_id() != Some(Id::from(set.account_id))))
|
&& 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
|
match self
|
||||||
.registry()
|
.registry()
|
||||||
.write(RegistryWrite::Delete {
|
.write(RegistryWrite::Delete {
|
||||||
@@ -863,6 +882,15 @@ impl RegistrySet for Server {
|
|||||||
set.response.destroyed.push(id);
|
set.response.destroyed.push(id);
|
||||||
}
|
}
|
||||||
err => {
|
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));
|
set.response.not_destroyed.append(id, map_write_error(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1744,6 +1744,8 @@ pub enum Permission {
|
|||||||
SysLegalHoldCreate = 670,
|
SysLegalHoldCreate = 670,
|
||||||
SysLegalHoldUpdate = 671,
|
SysLegalHoldUpdate = 671,
|
||||||
SysLegalHoldExport = 672,
|
SysLegalHoldExport = 672,
|
||||||
|
// inbuxa: personal-data catalog, the data inventory and compliance overview
|
||||||
|
SysComplianceGet = 673,
|
||||||
SysAccountGet = 219,
|
SysAccountGet = 219,
|
||||||
SysAccountCreate = 220,
|
SysAccountCreate = 220,
|
||||||
SysAccountUpdate = 221,
|
SysAccountUpdate = 221,
|
||||||
|
|||||||
@@ -7084,6 +7084,7 @@ impl EnumImpl for Permission {
|
|||||||
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
|
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
|
||||||
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
|
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
|
||||||
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
|
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
|
||||||
|
b"sysComplianceGet" => Permission::SysComplianceGet,
|
||||||
b"sysAccountGet" => Permission::SysAccountGet,
|
b"sysAccountGet" => Permission::SysAccountGet,
|
||||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||||
@@ -7773,6 +7774,7 @@ impl EnumImpl for Permission {
|
|||||||
Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
|
Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
|
||||||
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
|
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
|
||||||
Permission::SysLegalHoldExport => "sysLegalHoldExport",
|
Permission::SysLegalHoldExport => "sysLegalHoldExport",
|
||||||
|
Permission::SysComplianceGet => "sysComplianceGet",
|
||||||
Permission::SysAccountGet => "sysAccountGet",
|
Permission::SysAccountGet => "sysAccountGet",
|
||||||
Permission::SysAccountCreate => "sysAccountCreate",
|
Permission::SysAccountCreate => "sysAccountCreate",
|
||||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||||
@@ -8455,6 +8457,7 @@ impl EnumImpl for Permission {
|
|||||||
670 => Some(Permission::SysLegalHoldCreate),
|
670 => Some(Permission::SysLegalHoldCreate),
|
||||||
671 => Some(Permission::SysLegalHoldUpdate),
|
671 => Some(Permission::SysLegalHoldUpdate),
|
||||||
672 => Some(Permission::SysLegalHoldExport),
|
672 => Some(Permission::SysLegalHoldExport),
|
||||||
|
673 => Some(Permission::SysComplianceGet),
|
||||||
219 => Some(Permission::SysAccountGet),
|
219 => Some(Permission::SysAccountGet),
|
||||||
220 => Some(Permission::SysAccountCreate),
|
220 => Some(Permission::SysAccountCreate),
|
||||||
221 => Some(Permission::SysAccountUpdate),
|
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 {
|
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
|
server-only by the tenant ceiling (LH-13), and it sees the tenant's slice of
|
||||||
the inventory only (§6).
|
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
|
### Reaching existing servers
|
||||||
|
|
||||||
New permissions get ids 673 onward and `COUNT` grows (`enums.rs`,
|
New permissions get ids 673 onward and `COUNT` grows (`enums.rs`,
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
MiWRzsz0AHdRshG_8JimktRqBO_pHGAUBHFcfV5jwI4
|
nh0Vx79fhlQAOjCC9it831hBytCjhGPloPm9qidUhGc
|
||||||
@@ -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 ai_explain;
|
||||||
pub mod account_lock; // inbuxa: account lock with delegation
|
pub mod account_lock; // inbuxa: account lock with delegation
|
||||||
pub mod legal_hold; // inbuxa: legal hold
|
pub mod legal_hold; // inbuxa: legal hold
|
||||||
|
pub mod compliance; // inbuxa: the compliance roles
|
||||||
pub mod audit; // inbuxa: the audit log
|
pub mod audit; // inbuxa: the audit log
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||||
|
|||||||
Reference in New Issue
Block a user