From 8afaee7d216596d8f355622eb07de284b6ec0e1c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 17:29:32 -0700 Subject: [PATCH] DLP and mail flow rules: inbuxa:MailRule over JMAP, and its permissions Phase 2e of the DLP and mail flow rules spec, the API half. - inbuxa:MailRule/get and /set under urn:inbuxa:jmap. Rules convert through serde, so what a client sends is the stored format. A create or change is validated whole (Rule::validate) and refused with the property at fault; id, createdBy, createdAt and updatedAt are the server's. Every change goes through the request layer's audit record. - Six permissions, ids 674-679 (enum and schema labels): mail flow rules (sysMailRuleGet/Update), DLP rules (sysDlpPolicyGet/Update) and held mail (sysDlpReviewGet/Update, for phase 3). Either kind's permission gets through the gate; the handler shows and changes each rule only with its own kind's. All server-level: a tenant is refused (settled answer 3). - Administrators get all six; the server-level Compliance Officer gets DLP rules to see and held mail to review (settled answer 4), added once to an existing server's officer role by the grant mechanism, which gains an officer audience. - Privacy catalog entry for inbuxa:MailRule. tests/src/system/mail_rules.rs: create, list in order, validation, server-set properties refused, update, kind-separated permissions for an officer, destroy, audit records. --- crates/common/src/auth/permissions.rs | 11 + crates/common/src/manager/compliance_roles.rs | 41 ++- .../common/src/manager/granted_permissions.rs | 97 ++++-- .../jmap-proto/src/object/inbuxa_mail_rule.rs | 218 ++++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/method.rs | 10 +- crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 14 + crates/jmap/src/api/auth.rs | 24 ++ crates/jmap/src/api/request.rs | 24 ++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/mail_rule.rs | 327 ++++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/registry/src/schema/enums.rs | 7 + crates/registry/src/schema/enums_impl.rs | 20 +- resources/privacy/catalog.toml | 15 + resources/schema/schema.json.gz | Bin 153302 -> 153379 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/mail_rules.rs | 201 +++++++++++ tests/src/system/mod.rs | 1 + 23 files changed, 994 insertions(+), 45 deletions(-) create mode 100644 crates/jmap-proto/src/object/inbuxa_mail_rule.rs create mode 100644 crates/jmap/src/inbuxa/mail_rule.rs create mode 100644 tests/src/system/mail_rules.rs 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 5822e0bfd25a5dc8449ec70f3b06a3c66bfd767e..e14adf65072e60c58d3628b0a0181cb1b60fa130 100644 GIT binary patch delta 18933 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 UfMTbvOWWiB7ren|6|VIT0K(uN9RL6T delta 18855 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 (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account.jmap_request(USING, json!([[method, arguments, "0"]])).await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + (call[0].as_str().unwrap_or_default().to_string(), call[1].clone()) +} + +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