diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 8fac42f..04a98be 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -296,6 +296,17 @@ impl Default for DefaultPermissions { default.superuser.push(permission); default.tenant.push(permission); } + // inbuxa: DLP and mail flow rules, and held mail, are the + // server's: never a tenant's (dlp-and-mail-flow-rules spec, + // settled answer 3) + Permission::SysMailRuleGet + | Permission::SysMailRuleUpdate + | Permission::SysDlpPolicyGet + | Permission::SysDlpPolicyUpdate + | Permission::SysDlpReviewGet + | Permission::SysDlpReviewUpdate => { + default.superuser.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 index e9e2733..3b53f5f 100644 --- a/crates/common/src/manager/compliance_roles.rs +++ b/crates/common/src/manager/compliance_roles.rs @@ -65,6 +65,10 @@ const OFFICER: &[Permission] = &[ Permission::SysLegalHoldUpdate, Permission::SysLegalHoldExport, Permission::SysAccountLockGet, + // dlp-and-mail-flow-rules spec, §2.8: see DLP rules, review held mail + Permission::SysDlpPolicyGet, + Permission::SysDlpReviewGet, + Permission::SysDlpReviewUpdate, ]; /// What a tenant's officer holds besides [`READS`]. @@ -113,6 +117,11 @@ fn created_key(tenant: Option) -> ValueClass { }) } +/// The server-level Compliance Officer role the server made, if it has. +pub async fn server_role(data: &Store) -> trc::Result> { + recorded(data, None).await +} + async fn recorded(data: &Store, tenant: Option) -> trc::Result> { Ok(data .get_value::(ValueKey::from(created_key(tenant))) @@ -172,13 +181,19 @@ pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) -> /// 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(|_| ()) + 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 { +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); }; @@ -220,7 +235,9 @@ mod tests { // 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"); + // Placing holds and reviewing held mail are the officer's + // job, not settings (settled answers 2 and 4) + let holds = name.starts_with("sysLegalHold") || name.starts_with("sysDlpReview"); assert!( !(name.ends_with("Update") && !holds) && !(name.ends_with("Create") && !holds) @@ -249,7 +266,11 @@ mod tests { assert!(officer.contains(&hold)); assert!(!tenant.contains(&hold)); } - for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] { + for both in [ + Permission::SysComplianceGet, + Permission::SysAuditGet, + Permission::SysAccountGet, + ] { assert!(officer.contains(&both) && tenant.contains(&both)); } assert!(!officer.contains(&Permission::SysAuditSettingsUpdate)); @@ -257,9 +278,15 @@ mod tests { #[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!() }; + 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/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 3c7d996..fac45e4 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -46,6 +46,21 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysLegalHoldUpdate, Permission::SysLegalHoldExport, Permission::SysComplianceGet, + Permission::SysMailRuleGet, + Permission::SysMailRuleUpdate, + Permission::SysDlpPolicyGet, + Permission::SysDlpPolicyUpdate, + Permission::SysDlpReviewGet, + Permission::SysDlpReviewUpdate, +]; + +/// Granted to the server-level Compliance Officer role once it exists: +/// seeing DLP rules and reviewing held mail (dlp-and-mail-flow-rules spec, +/// §2.8, settled answer 4). A new install's role has them from the start. +const OFFICER_GRANTS: &[Permission] = &[ + Permission::SysDlpPolicyGet, + Permission::SysDlpReviewGet, + Permission::SysDlpReviewUpdate, ]; /// Granted to the default tenant administrator roles: reading and exporting @@ -65,13 +80,16 @@ const TENANT_GRANTS: &[Permission] = &[ enum Audience { Admin, Tenant, + Officer, } fn granted_key(permission: Permission, audience: Audience) -> ValueClass { let mut key = b"Pg".to_vec(); // Admin grants keep the key they were first recorded under - if audience == Audience::Tenant { - key.extend_from_slice(b"tenant:"); + match audience { + Audience::Admin => {} + Audience::Tenant => key.extend_from_slice(b"tenant:"), + Audience::Officer => key.extend_from_slice(b"officer:"), } key.extend_from_slice(permission.as_str().as_bytes()); ValueClass::Any(AnyClass { @@ -82,7 +100,8 @@ fn granted_key(permission: Permission, audience: Audience) -> ValueClass { pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> { grant(bp, Audience::Admin, ADMIN_GRANTS).await?; - grant(bp, Audience::Tenant, TENANT_GRANTS).await + grant(bp, Audience::Tenant, TENANT_GRANTS).await?; + grant(bp, Audience::Officer, OFFICER_GRANTS).await } async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> { @@ -101,39 +120,47 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> if pending.is_empty() { return Ok(()); } - // An administrator's default roles include the plain User role, which - // every user also holds; only roles that are the audience's alone get it - let admin_roles: Vec = bp - .registry - .object::(Id::singleton()) - .await? - .map(|auth| { - let (own, shared) = match audience { - Audience::Admin => ( - auth.default_admin_role_ids.as_slice(), - [ - auth.default_user_role_ids.as_slice(), - auth.default_group_role_ids.as_slice(), - auth.default_tenant_role_ids.as_slice(), - ] - .concat(), - ), - Audience::Tenant => ( - auth.default_tenant_role_ids.as_slice(), - [ - auth.default_user_role_ids.as_slice(), - auth.default_group_role_ids.as_slice(), + // The officer role is the one the server made, if it has made it yet: a + // new install makes it after this, with the permissions already in it + let admin_roles: Vec = if audience == Audience::Officer { + super::compliance_roles::server_role(&bp.data_store) + .await? + .into_iter() + .collect() + } else { + // An administrator's default roles include the plain User role, which + // every user also holds; only roles that are the audience's alone get it + bp.registry + .object::(Id::singleton()) + .await? + .map(|auth| { + let (own, shared) = match audience { + Audience::Admin => ( auth.default_admin_role_ids.as_slice(), - ] - .concat(), - ), - }; - own.iter() - .filter(|id| !shared.contains(id)) - .copied() - .collect() - }) - .unwrap_or_default(); + [ + auth.default_user_role_ids.as_slice(), + auth.default_group_role_ids.as_slice(), + auth.default_tenant_role_ids.as_slice(), + ] + .concat(), + ), + Audience::Tenant | Audience::Officer => ( + auth.default_tenant_role_ids.as_slice(), + [ + auth.default_user_role_ids.as_slice(), + auth.default_group_role_ids.as_slice(), + auth.default_admin_role_ids.as_slice(), + ] + .concat(), + ), + }; + own.iter() + .filter(|id| !shared.contains(id)) + .copied() + .collect() + }) + .unwrap_or_default() + }; // Fetched by id: the registry's listing doesn't reach stored roles for role_id in admin_roles { let Some(stored) = bp diff --git a/crates/jmap-proto/src/object/inbuxa_mail_rule.rs b/crates/jmap-proto/src/object/inbuxa_mail_rule.rs new file mode 100644 index 0000000..7c90dab --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_mail_rule.rs @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:MailRule/get` and `/set` under `urn:inbuxa:jmap`: mail flow rules +//! and DLP rules (dlp-and-mail-flow-rules spec, §2.2). `kind` says which, +//! and which permissions reach it. The set call's `reason` argument, if +//! given, goes into the audit log with the change. + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct MailRule; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailRuleProperty { + Id, + Name, + Description, + /// `dlp` or `transport`. + Kind, + Enabled, + /// Lower runs first. + Priority, + /// `outgoing`, `incoming` or `any`. + Direction, + Conditions, + Exceptions, + Actions, + StopProcessing, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailRuleValue { + Id(Id), +} + +impl Property for MailRuleProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside conditions and actions stay plain keys + match parent { + None => MailRuleProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + MailRuleProperty::Id => "id", + MailRuleProperty::Name => "name", + MailRuleProperty::Description => "description", + MailRuleProperty::Kind => "kind", + MailRuleProperty::Enabled => "enabled", + MailRuleProperty::Priority => "priority", + MailRuleProperty::Direction => "direction", + MailRuleProperty::Conditions => "conditions", + MailRuleProperty::Exceptions => "exceptions", + MailRuleProperty::Actions => "actions", + MailRuleProperty::StopProcessing => "stopProcessing", + MailRuleProperty::CreatedBy => "createdBy", + MailRuleProperty::CreatedAt => "createdAt", + MailRuleProperty::UpdatedAt => "updatedAt", + } + .into() + } +} + +impl MailRuleProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => MailRuleProperty::Id, + b"name" => MailRuleProperty::Name, + b"description" => MailRuleProperty::Description, + b"kind" => MailRuleProperty::Kind, + b"enabled" => MailRuleProperty::Enabled, + b"priority" => MailRuleProperty::Priority, + b"direction" => MailRuleProperty::Direction, + b"conditions" => MailRuleProperty::Conditions, + b"exceptions" => MailRuleProperty::Exceptions, + b"actions" => MailRuleProperty::Actions, + b"stopProcessing" => MailRuleProperty::StopProcessing, + b"createdBy" => MailRuleProperty::CreatedBy, + b"createdAt" => MailRuleProperty::CreatedAt, + b"updatedAt" => MailRuleProperty::UpdatedAt, + ) + } +} + +impl FromStr for MailRuleProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + MailRuleProperty::parse(s).ok_or(()) + } +} + +impl Element for MailRuleValue { + type Property = MailRuleProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(MailRuleProperty::Id) => Id::from_str(value).ok().map(MailRuleValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + MailRuleValue::Id(id) => id.to_string().into(), + } + } +} + +/// The set call's own argument: why, for the audit log. +#[derive(Debug, Clone, Default)] +pub struct MailRuleSetArguments { + pub reason: Option, +} + +impl<'de> DeserializeArguments<'de> for MailRuleSetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "reason" { + self.reason = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl JmapObject for MailRule { + type Property = MailRuleProperty; + + type Element = MailRuleValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = MailRuleSetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = MailRuleProperty::Id; +} + +impl From for MailRuleValue { + fn from(id: Id) -> Self { + MailRuleValue::Id(id) + } +} + +impl JmapObjectId for MailRuleValue { + fn as_id(&self) -> Option { + match self { + MailRuleValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + MailRuleValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = MailRuleValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for MailRuleProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index b38b377..a85bb6f 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -28,6 +28,7 @@ pub mod inbuxa_data_inventory; // inbuxa: personal-data catalog pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_legal_hold; // inbuxa: legal hold +pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_hold_export; // inbuxa: legal hold exports pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index ffb7298..b2678aa 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -82,6 +82,9 @@ impl Response<'_> { GetResponseMethod::LegalHold(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::MailRule(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::HoldExport(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index f8a4f31..a447be1 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -53,6 +53,7 @@ impl Response<'_> { GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, + GetRequestMethod::MailRule(request) => request.resolve_references(self)?, GetRequestMethod::HoldExport(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::TenantProtocolPolicy(request) => { @@ -122,6 +123,9 @@ impl Response<'_> { SetRequestMethod::LegalHold(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::MailRule(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::HoldExport(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 11549ce..8a91a58 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -65,6 +65,8 @@ pub enum MethodObject { LegalHold, HoldExport, ProtocolPolicy, + // inbuxa: DLP and mail flow rules + MailRule, TenantProtocolPolicy, } @@ -102,7 +104,8 @@ impl MethodObject { | MethodObject::AuditVerification | MethodObject::AccountLock | MethodObject::LegalHold - | MethodObject::HoldExport => Capability::Inbuxa, + | MethodObject::HoldExport + | MethodObject::MailRule => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -296,6 +299,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set", (MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get", (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", + (MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get", + (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", (MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get", (MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set", (MethodFunction::Set, MethodObject::AuditVerification) => { @@ -448,6 +453,8 @@ impl MethodName { "inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set), "inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get), "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), + "inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get), + "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), "inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get), "inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set), "inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set), @@ -518,6 +525,7 @@ impl Display for MethodObject { MethodObject::AuditVerification => "inbuxa:AuditVerification", MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::LegalHold => "inbuxa:LegalHold", + MethodObject::MailRule => "inbuxa:MailRule", MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 263441e..1d5f0e1 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -123,6 +123,7 @@ pub enum GetRequestMethod { AuditSettings(Box>), AccountLock(Box>), LegalHold(Box>), + MailRule(Box>), HoldExport(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -158,6 +159,7 @@ pub enum SetRequestMethod<'x> { AuditVerification(Box>), AccountLock(Box>), LegalHold(Box>), + MailRule(Box>), HoldExport(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 8c86a70..28512b9 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -609,6 +609,21 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: DLP and mail flow rules + (MethodFunction::Get, MethodObject::MailRule) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MailRule(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::MailRule) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MailRule(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: legal hold (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 9b74daa..67eaaf8 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -110,6 +110,7 @@ pub enum GetResponseMethod { AuditSettings(GetResponse), AccountLock(GetResponse), LegalHold(GetResponse), + MailRule(GetResponse), HoldExport(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( @@ -145,6 +146,7 @@ pub enum SetResponseMethod { AuditVerification(Box>), AccountLock(Box>), LegalHold(Box>), + MailRule(Box>), HoldExport(Box>), Explanation(Box>), ProtocolPolicy(Box>), @@ -799,6 +801,18 @@ impl<'x> From> for } // inbuxa: legal hold +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::MailRule(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::MailRule(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::LegalHold(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 5955c74..27d808c 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -103,6 +103,16 @@ impl JmapAuthorization for AccessToken { // inbuxa: account lock (AL-12) GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet, GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet, + // inbuxa: DLP and mail flow rules share an object; either + // permission reaches it, and the handler shows each kind + // only to those who may see it + GetRequestMethod::MailRule(_) => { + if self.has_permission(Permission::SysMailRuleGet) { + Permission::SysMailRuleGet + } else { + Permission::SysDlpPolicyGet + } + } GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions @@ -247,6 +257,19 @@ impl JmapAuthorization for AccessToken { Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate, ), + // inbuxa: DLP and mail flow rules: either change + // permission gets in; the handler checks each rule's kind + SetRequestMethod::MailRule(_) => { + if self.has_permission(Permission::SysMailRuleUpdate) + || self.has_permission(Permission::SysDlpPolicyUpdate) + { + Ok(()) + } else { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("You are not authorized to change mail rules")) + } + } // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -407,6 +430,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::AccountLock | MethodObject::LegalHold | MethodObject::HoldExport + | MethodObject::MailRule | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 180e597..903bb61 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -279,6 +279,9 @@ impl RequestHandler for Server { SetResponseMethod::LegalHold(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::MailRule(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::HoldExport(set_response) => { set_response.update_created_ids(&mut response); } @@ -486,6 +489,11 @@ impl RequestHandler for Server { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::legal_hold::get(self, *req).await?.into() } + // inbuxa: DLP and mail flow rules + GetRequestMethod::MailRule(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into() + } // inbuxa: the audit log (AU-9) GetRequestMethod::AuditEvent(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -910,6 +918,22 @@ impl RequestHandler for Server { .await? .into() } + SetRequestMethod::MailRule(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + let reason = req.arguments.reason.clone(); + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + reason, + *req, + |req| Box::pin(crate::inbuxa::mail_rule::set(self, access_token, req)), + ) + .await? + .into() + } SetRequestMethod::AuditExport(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; crate::inbuxa::audit_log::export_set(self, access_token, session, *req) diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 5ce0da1..2888c77 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -429,6 +429,7 @@ impl IntermediateChangesResponse { | MethodObject::AccountLock | MethodObject::LegalHold | MethodObject::HoldExport + | MethodObject::MailRule | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/inbuxa/mail_rule.rs b/crates/jmap/src/inbuxa/mail_rule.rs new file mode 100644 index 0000000..5b43abf --- /dev/null +++ b/crates/jmap/src/inbuxa/mail_rule.rs @@ -0,0 +1,327 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:MailRule` (dlp-and-mail-flow-rules spec, §2.2, §2.8): mail flow +//! rules and DLP rules. One object, two kinds, each with its own +//! permissions: `sysMailRuleGet`/`Update` for transport rules, +//! `sysDlpPolicyGet`/`Update` for DLP rules. Rules are the server's: nobody +//! in a tenant reaches them (settled answer 3). The request layer records +//! every change in the audit log. + +use common::{Server, auth::AccessToken}; +use inbuxa_features::mailflow::rules::{self, Kind, Rule}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_mail_rule::{MailRule, MailRuleProperty as P, MailRuleValue}, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use registry::schema::enums::Permission; +use std::borrow::Cow; +use store::write::now; +use types::id::Id; + +type RValue = Value<'static, P, MailRuleValue>; + +const ALL: &[P] = &[ + P::Id, + P::Name, + P::Description, + P::Kind, + P::Enabled, + P::Priority, + P::Direction, + P::Conditions, + P::Exceptions, + P::Actions, + P::StopProcessing, + P::CreatedBy, + P::CreatedAt, + P::UpdatedAt, +]; + +/// Properties the server sets; a client that sends them is refused. +const SERVER_SET: &[P] = &[P::Id, P::CreatedBy, P::CreatedAt, P::UpdatedAt]; + +fn can_see(access_token: &AccessToken, kind: Kind) -> bool { + access_token.has_permission(match kind { + Kind::Dlp => Permission::SysDlpPolicyGet, + Kind::Transport => Permission::SysMailRuleGet, + }) +} + +fn can_change(access_token: &AccessToken, kind: Kind) -> bool { + access_token.has_permission(match kind { + Kind::Dlp => Permission::SysDlpPolicyUpdate, + Kind::Transport => Permission::SysMailRuleUpdate, + }) +} + +fn server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("Mail rules are the server's.")) + } else { + Ok(()) + } +} + +fn json_to_value(json: serde_json::Value) -> RValue { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn date(seconds: u64) -> RValue { + Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into()) +} + +fn to_value(rule: &Rule, properties: &[P]) -> RValue { + let json = serde_json::to_value(rule).unwrap_or_default(); + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(MailRuleValue::Id(Id::from(rule.id))), + P::CreatedAt => date(rule.created_at), + P::UpdatedAt => date(rule.updated_at), + other => json + .get(other.to_cow().as_ref()) + .cloned() + .map_or(Value::Null, json_to_value), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// A rule as sent: its JSON object, top-level keys only those a client may +/// set. +fn client_json(value: Value<'_, P, MailRuleValue>) -> Result, SetError

> { + let mut map = serde_json::Map::new(); + for (key, value) in value.into_expanded_object() { + match &key { + Key::Property(p) if SERVER_SET.contains(p) => { + return Err(SetError::invalid_properties() + .with_property(p.clone()) + .with_description("The server sets this.")); + } + Key::Property(p) => { + map.insert(p.to_cow().into_owned(), value.into()); + } + _ => { + return Err(SetError::invalid_properties().with_property(key.clone().into_owned())); + } + } + } + Ok(map) +} + +fn parse(json: serde_json::Map) -> Result> { + let rule: Rule = serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| { + SetError::invalid_properties().with_description(format!("Not a valid rule: {err}")) + })?; + rule.validate().map_err(|invalid| { + let property = invalid.property.parse::

().unwrap_or(P::Name); + SetError::invalid_properties() + .with_property(property) + .with_description(invalid.reason) + })?; + Ok(rule) +} + +fn forbidden(kind: Kind) -> SetError

{ + SetError::forbidden().with_description(match kind { + Kind::Dlp => "Changing DLP rules needs the permission to change DLP rules.", + Kind::Transport => "Changing mail flow rules needs the permission to change them.", + }) +} + +fn rule_id(id: Id) -> Option { + u32::try_from(id.id()).ok() +} + +/// `inbuxa:MailRule/get`: the rules the caller may see, in the order they +/// run. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let visible: Vec = rules::all(server.store()) + .await? + .into_iter() + .filter(|rule| can_see(access_token, rule.kind)) + .collect(); + match ids { + None => { + response.list = visible + .iter() + .map(|rule| to_value(rule, &properties)) + .collect() + } + Some(ids) => { + for id in ids { + match rule_id(id).and_then(|id| visible.iter().find(|r| r.id == id)) { + Some(rule) => response.list.push(to_value(rule, &properties)), + None => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:MailRule/set`: create, change or delete rules, each checked +/// against the permissions for its kind (and, on a change of kind, both). +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, MailRule>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let data = server.store(); + let actor = server.audit_actor(access_token).await; + + for (client_id, value) in request.unwrap_create() { + let rule = match client_json(value).and_then(parse) { + Ok(rule) => rule, + Err(error) => { + response.not_created.append(client_id, error); + continue; + } + }; + if !can_change(access_token, rule.kind) { + response.not_created.append(client_id, forbidden(rule.kind)); + continue; + } + let at = now(); + let rule = Rule { + created_by: actor.name.clone(), + created_at: at, + updated_at: at, + ..rule + }; + let id = rules::create(data, &rule).await?; + let mut out = Map::with_capacity(1); + out.insert_unchecked( + Key::Property(P::Id), + Value::Element(MailRuleValue::Id(Id::from(id))), + ); + response.created.insert(client_id, Value::Object(out)); + } + + for (id, value) in request.unwrap_update().into_valid() { + let Some(current) = (match rule_id(id) { + Some(rule_id) => rules::get(data, rule_id).await?, + None => None, + }) else { + response.not_updated.append(id, SetError::not_found()); + continue; + }; + if !can_see(access_token, current.kind) { + response.not_updated.append(id, SetError::not_found()); + continue; + } + if !can_change(access_token, current.kind) { + response.not_updated.append(id, forbidden(current.kind)); + continue; + } + // The stored rule, with each property sent replacing its own + let mut json = match serde_json::to_value(¤t) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + }; + let changes = match client_json(value) { + Ok(changes) => changes, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + json.extend(changes); + let next = match parse(json) { + Ok(next) => next, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + if next.kind != current.kind && !can_change(access_token, next.kind) { + response.not_updated.append(id, forbidden(next.kind)); + continue; + } + let next = Rule { + id: current.id, + created_by: current.created_by.clone(), + created_at: current.created_at, + updated_at: now(), + ..next + }; + if next != current { + rules::update(data, &next).await?; + } + response.updated.append(id, None); + } + + for id in request.unwrap_destroy().into_valid() { + let Some(current) = (match rule_id(id) { + Some(rule_id) => rules::get(data, rule_id).await?, + None => None, + }) else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + if !can_see(access_token, current.kind) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + } + if !can_change(access_token, current.kind) { + response.not_destroyed.append(id, forbidden(current.kind)); + continue; + } + rules::delete(data, current.id).await?; + response.destroyed.push(id); + } + + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 44fafb5..aa72ecd 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -10,6 +10,7 @@ pub mod access; pub mod account_lock; pub mod legal_hold; +pub mod mail_rule; pub mod hold_export; pub mod hold_export_api; pub mod audit; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 440db94..f578905 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1748,6 +1748,13 @@ pub enum Permission { SysLegalHoldExport = 672, // inbuxa: personal-data catalog, the data inventory and compliance overview SysComplianceGet = 673, + // inbuxa: DLP and mail flow rules + SysMailRuleGet = 674, + SysMailRuleUpdate = 675, + SysDlpPolicyGet = 676, + SysDlpPolicyUpdate = 677, + SysDlpReviewGet = 678, + SysDlpReviewUpdate = 679, 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 912925e..ed72ffe 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7091,6 +7091,12 @@ impl EnumImpl for Permission { b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate, b"sysLegalHoldExport" => Permission::SysLegalHoldExport, b"sysComplianceGet" => Permission::SysComplianceGet, + b"sysMailRuleGet" => Permission::SysMailRuleGet, + b"sysMailRuleUpdate" => Permission::SysMailRuleUpdate, + b"sysDlpPolicyGet" => Permission::SysDlpPolicyGet, + b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate, + b"sysDlpReviewGet" => Permission::SysDlpReviewGet, + b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7781,6 +7787,12 @@ impl EnumImpl for Permission { Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate", Permission::SysLegalHoldExport => "sysLegalHoldExport", Permission::SysComplianceGet => "sysComplianceGet", + Permission::SysMailRuleGet => "sysMailRuleGet", + Permission::SysMailRuleUpdate => "sysMailRuleUpdate", + Permission::SysDlpPolicyGet => "sysDlpPolicyGet", + Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate", + Permission::SysDlpReviewGet => "sysDlpReviewGet", + Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8464,6 +8476,12 @@ impl EnumImpl for Permission { 671 => Some(Permission::SysLegalHoldUpdate), 672 => Some(Permission::SysLegalHoldExport), 673 => Some(Permission::SysComplianceGet), + 674 => Some(Permission::SysMailRuleGet), + 675 => Some(Permission::SysMailRuleUpdate), + 676 => Some(Permission::SysDlpPolicyGet), + 677 => Some(Permission::SysDlpPolicyUpdate), + 678 => Some(Permission::SysDlpReviewGet), + 679 => Some(Permission::SysDlpReviewUpdate), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8908,7 +8926,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 674; + const COUNT: usize = 680; } impl serde::Serialize for Permission { diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 80705aa..524954f 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -79,6 +79,21 @@ lockedAt = ["metadata"] lockedBy = ["identifier"] delegates = ["identifier"] +[object."inbuxa:MailRule"] +file = "inbuxa_mail_rule.rs" +default = "none" +whose = ["administrator", "holder", "correspondent"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:MailRule".properties] +name = ["content"] +description = ["content"] +conditions = ["contact", "content"] +exceptions = ["contact", "content"] +actions = ["contact", "content"] +createdBy = ["identifier"] + [object."inbuxa:LegalHold"] file = "inbuxa_legal_hold.rs" default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 5822e0b..e14adf6 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 cfffa0e..7ae9d8d 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU \ No newline at end of file +yF7PlBR3UBqxlabhW5zZ5WacQWsynG1wNYEiJVAl6w4 \ No newline at end of file diff --git a/tests/src/system/mail_rules.rs b/tests/src/system/mail_rules.rs new file mode 100644 index 0000000..38b9c26 --- /dev/null +++ b/tests/src/system/mail_rules.rs @@ -0,0 +1,201 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:MailRule` (dlp-and-mail-flow-rules spec, §2.2, §2.8): rules are +//! stored, listed in the order they run, checked when written, kept apart +//! by kind for permissions, and every change is audited. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CustomRoles, Role, UserRoles}, +}; +use registry::types::map::Map; +use serde_json::{Value, json}; + +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()) +} + +fn dlp_rule() -> Value { + json!({ + "name": "Cards leaving", + "kind": "dlp", + "direction": "outgoing", + "priority": 10, + "conditions": [ + {"type": "recipientOutside"}, + {"type": "detected", "detectors": [{"id": "payment-card", "atLeast": 5}]} + ], + "actions": [{"type": "hold", "notice": "Held for review", "notifySender": true}] + }) +} + +fn transport_rule() -> Value { + json!({ + "name": "Disclaimer", + "kind": "transport", + "direction": "outgoing", + "priority": 1, + "conditions": [{"type": "recipientOutside"}], + "actions": [{"type": "addDisclaimer", "text": "Sent by Example Co.", "position": "bottom"}] + }) +} + +async fn names(account: &Account) -> Vec { + let (name, response) = call(account, "inbuxa:MailRule/get", json!({"ids": null})).await; + assert_eq!(name, "inbuxa:MailRule/get", "{response}"); + response["list"] + .as_array() + .unwrap() + .iter() + .map(|r| r["name"].as_str().unwrap().to_string()) + .collect() +} + +pub async fn test(test: &mut TestServer) { + println!("Running mail rule tests..."); + let admin = test.account("admin@example.com"); + + // Created, then listed in the order they run + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"create": {"d": dlp_rule(), "t": transport_rule()}}), + ) + .await; + let dlp_id = response["created"]["d"]["id"] + .as_str() + .unwrap_or_else(|| panic!("DLP rule created: {response}")) + .to_string(); + let transport_id = response["created"]["t"]["id"].as_str().unwrap().to_string(); + assert_eq!(names(&admin).await, vec!["Disclaimer", "Cards leaving"]); + + let (_, response) = call(&admin, "inbuxa:MailRule/get", json!({"ids": [dlp_id]})).await; + let rule = &response["list"][0]; + assert_eq!(rule["conditions"][1]["detectors"][0]["atLeast"], 5, "{rule}"); + assert_eq!(rule["actions"][0]["notifySender"], true); + assert_eq!(rule["createdBy"], "admin@example.com"); + assert!(rule["createdAt"].as_str().is_some_and(|d| d.ends_with('Z')), "{rule}"); + + // Checked when written + let mut inbound = dlp_rule(); + inbound["direction"] = "incoming".into(); + let mut unknown = dlp_rule(); + unknown["conditions"][1]["detectors"][0]["id"] = "no-such-detector".into(); + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"create": {"a": inbound, "b": unknown, "c": {"name": "x", "kind": "dlp"}}}), + ) + .await; + assert_eq!(response["notCreated"]["a"]["properties"][0], "direction", "{response}"); + assert!( + response["notCreated"]["b"]["description"].as_str().unwrap().contains("no-such-detector"), + "{response}" + ); + assert!(response["notCreated"].get("c").is_some(), "{response}"); + + // Changed in place; what the server sets can't be sent + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"update": { + transport_id.as_str(): {"name": "Footer", "priority": 50}, + dlp_id.as_str(): {"createdBy": "someone else"} + }}), + ) + .await; + assert!(response["updated"].get(transport_id.as_str()).is_some(), "{response}"); + assert_eq!(response["notUpdated"][dlp_id.as_str()]["properties"][0], "createdBy", "{response}"); + assert_eq!(names(&admin).await, vec!["Cards leaving", "Footer"]); + + // A compliance officer sees DLP rules, not mail flow rules, and changes + // neither (settled answer 4) + let mut officer_role = None; + 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 == "Compliance Officer" && role.member_tenant_id.is_none() { + officer_role = Some(id); + } + } + let officer = admin + .create_user_account("rules-officer@example.com", "officer-secret-7731", "Officer", &[], vec![]) + .await; + admin + .registry_update_object( + ObjectType::Account, + officer.id(), + json!({Property::Roles: UserRoles::Custom(CustomRoles { + role_ids: Map::new(vec![officer_role.expect("the officer role")]), + })}), + ) + .await; + assert_eq!(names(&officer).await, vec!["Cards leaving"]); + let (name, response) = + call(&officer, "inbuxa:MailRule/set", json!({"create": {"d": dlp_rule()}})).await; + assert_eq!(name, "error", "the officer created a DLP rule: {response}"); + + // Deleted + let (_, response) = call( + &admin, + "inbuxa:MailRule/set", + json!({"destroy": [transport_id]}), + ) + .await; + assert_eq!(response["destroyed"][0], transport_id.as_str(), "{response}"); + assert_eq!(names(&admin).await, vec!["Cards leaving"]); + + // Every change is in the audit log + let (_, response) = call( + &admin, + "inbuxa:AuditEvent/query", + json!({"filter": {"targetKind": "inbuxa:MailRule"}, "calculateTotal": true}), + ) + .await; + assert!( + response["total"].as_u64().unwrap_or(0) >= 4, + "creates, update and destroy audited: {response}" + ); +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn mail_rules_tests() { + let mut test = TestServerBuilder::new("mail_rules_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 e0d212e..4c51b14 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -14,6 +14,7 @@ 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 mail_rules; // inbuxa: DLP and mail flow rules pub mod audit; // inbuxa: the audit log pub mod authorization; pub mod auto_reload; // inbuxa: registry writes apply at once