DLP and mail flow rules: inbuxa:MailRule over JMAP, and its permissions
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 4m41s

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.
This commit is contained in:
2026-09-28 17:29:35 -07:00
parent c8280de9c3
commit 8afaee7d21
23 changed files with 994 additions and 45 deletions
+11
View File
@@ -296,6 +296,17 @@ impl Default for DefaultPermissions {
default.superuser.push(permission); default.superuser.push(permission);
default.tenant.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 // inbuxa: AL-12: tenant administrators lock and delegate
// within their tenant // within their tenant
Permission::SysAccountLockGet Permission::SysAccountLockGet
+34 -7
View File
@@ -65,6 +65,10 @@ const OFFICER: &[Permission] = &[
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport, Permission::SysLegalHoldExport,
Permission::SysAccountLockGet, 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`]. /// What a tenant's officer holds besides [`READS`].
@@ -113,6 +117,11 @@ fn created_key(tenant: Option<Id>) -> ValueClass {
}) })
} }
/// The server-level Compliance Officer role the server made, if it has.
pub async fn server_role(data: &Store) -> trc::Result<Option<Id>> {
recorded(data, None).await
}
async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> { async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> {
Ok(data Ok(data
.get_value::<u64>(ValueKey::from(created_key(tenant))) .get_value::<u64>(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. /// A new tenant gets its Compliance Officer role.
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> { 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 /// 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, /// 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. /// so a delete refused for another reason can put it back.
pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<bool> { pub async fn tenant_deleting(
registry: &RegistryStore,
data: &Store,
tenant: Id,
) -> trc::Result<bool> {
let Some(role) = recorded(data, Some(tenant)).await? else { let Some(role) = recorded(data, Some(tenant)).await? else {
return Ok(false); return Ok(false);
}; };
@@ -220,7 +235,9 @@ mod tests {
// Beyond what any user holds for their own account // Beyond what any user holds for their own account
for permission in all.into_iter().filter(|p| !user.contains(p)) { for permission in all.into_iter().filter(|p| !user.contains(p)) {
let name = permission.as_str(); 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!( assert!(
!(name.ends_with("Update") && !holds) !(name.ends_with("Update") && !holds)
&& !(name.ends_with("Create") && !holds) && !(name.ends_with("Create") && !holds)
@@ -249,7 +266,11 @@ mod tests {
assert!(officer.contains(&hold)); assert!(officer.contains(&hold));
assert!(!tenant.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(&both) && tenant.contains(&both));
} }
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate)); assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
@@ -257,9 +278,15 @@ mod tests {
#[test] #[test]
fn records_are_per_place() { fn records_are_per_place() {
let ValueClass::Any(server) = created_key(None) else { panic!() }; let ValueClass::Any(server) = created_key(None) else {
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() }; panic!()
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) 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_eq!(server.key, b"Pc");
assert_ne!(a.key, b.key); assert_ne!(a.key, b.key);
assert!(a.key.starts_with(b"Pc")); assert!(a.key.starts_with(b"Pc"));
@@ -46,6 +46,21 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport, Permission::SysLegalHoldExport,
Permission::SysComplianceGet, 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 /// Granted to the default tenant administrator roles: reading and exporting
@@ -65,13 +80,16 @@ const TENANT_GRANTS: &[Permission] = &[
enum Audience { enum Audience {
Admin, Admin,
Tenant, Tenant,
Officer,
} }
fn granted_key(permission: Permission, audience: Audience) -> ValueClass { fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
let mut key = b"Pg".to_vec(); let mut key = b"Pg".to_vec();
// Admin grants keep the key they were first recorded under // Admin grants keep the key they were first recorded under
if audience == Audience::Tenant { match audience {
key.extend_from_slice(b"tenant:"); 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()); key.extend_from_slice(permission.as_str().as_bytes());
ValueClass::Any(AnyClass { 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<()> { pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
grant(bp, Audience::Admin, ADMIN_GRANTS).await?; 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<()> { async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
@@ -101,10 +120,17 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
if pending.is_empty() { if pending.is_empty() {
return Ok(()); return Ok(());
} }
// 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<Id> = 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 // 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 // every user also holds; only roles that are the audience's alone get it
let admin_roles: Vec<Id> = bp bp.registry
.registry
.object::<Authentication>(Id::singleton()) .object::<Authentication>(Id::singleton())
.await? .await?
.map(|auth| { .map(|auth| {
@@ -118,7 +144,7 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
] ]
.concat(), .concat(),
), ),
Audience::Tenant => ( Audience::Tenant | Audience::Officer => (
auth.default_tenant_role_ids.as_slice(), auth.default_tenant_role_ids.as_slice(),
[ [
auth.default_user_role_ids.as_slice(), auth.default_user_role_ids.as_slice(),
@@ -133,7 +159,8 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
.copied() .copied()
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default()
};
// Fetched by id: the registry's listing doesn't reach stored roles // Fetched by id: the registry's listing doesn't reach stored roles
for role_id in admin_roles { for role_id in admin_roles {
let Some(stored) = bp let Some(stored) = bp
@@ -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<Self> {
// 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<Self> {
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<Self, Self::Err> {
MailRuleProperty::parse(s).ok_or(())
}
}
impl Element for MailRuleValue {
type Property = MailRuleProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
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<String>,
}
impl<'de> DeserializeArguments<'de> for MailRuleSetArguments {
fn deserialize_argument<A>(&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::<serde::de::IgnoredAny>()?;
}
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<Id> for MailRuleValue {
fn from(id: Id) -> Self {
MailRuleValue::Id(id)
}
}
impl JmapObjectId for MailRuleValue {
fn as_id(&self) -> Option<Id> {
match self {
MailRuleValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
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<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -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_inventory_snapshot; // inbuxa: personal-data catalog
pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_audit; // inbuxa: the audit log
pub mod inbuxa_legal_hold; // inbuxa: legal hold 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_hold_export; // inbuxa: legal hold exports
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
+3
View File
@@ -82,6 +82,9 @@ impl Response<'_> {
GetResponseMethod::LegalHold(response) => { GetResponseMethod::LegalHold(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::MailRule(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::HoldExport(response) => { GetResponseMethod::HoldExport(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -53,6 +53,7 @@ impl Response<'_> {
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
GetRequestMethod::LegalHold(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::HoldExport(request) => request.resolve_references(self)?,
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::TenantProtocolPolicy(request) => { GetRequestMethod::TenantProtocolPolicy(request) => {
@@ -122,6 +123,9 @@ impl Response<'_> {
SetRequestMethod::LegalHold(request) => { SetRequestMethod::LegalHold(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::MailRule(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::HoldExport(request) => { SetRequestMethod::HoldExport(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
+9 -1
View File
@@ -65,6 +65,8 @@ pub enum MethodObject {
LegalHold, LegalHold,
HoldExport, HoldExport,
ProtocolPolicy, ProtocolPolicy,
// inbuxa: DLP and mail flow rules
MailRule,
TenantProtocolPolicy, TenantProtocolPolicy,
} }
@@ -102,7 +104,8 @@ impl MethodObject {
| MethodObject::AuditVerification | MethodObject::AuditVerification
| MethodObject::AccountLock | MethodObject::AccountLock
| MethodObject::LegalHold | MethodObject::LegalHold
| MethodObject::HoldExport => Capability::Inbuxa, | MethodObject::HoldExport
| MethodObject::MailRule => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
} }
@@ -296,6 +299,8 @@ impl MethodName {
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set", (MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get", (MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", (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::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set", (MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
(MethodFunction::Set, MethodObject::AuditVerification) => { (MethodFunction::Set, MethodObject::AuditVerification) => {
@@ -448,6 +453,8 @@ impl MethodName {
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set), "inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get), "inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), "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/get" => (MethodObject::HoldExport, MethodFunction::Get),
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set), "inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set), "inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
@@ -518,6 +525,7 @@ impl Display for MethodObject {
MethodObject::AuditVerification => "inbuxa:AuditVerification", MethodObject::AuditVerification => "inbuxa:AuditVerification",
MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::AccountLock => "inbuxa:AccountLock",
MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::LegalHold => "inbuxa:LegalHold",
MethodObject::MailRule => "inbuxa:MailRule",
MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
+2
View File
@@ -123,6 +123,7 @@ pub enum GetRequestMethod {
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>), AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>), HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
@@ -158,6 +159,7 @@ pub enum SetRequestMethod<'x> {
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>), AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<SetRequest<'x, crate::object::inbuxa_mail_rule::MailRule>>),
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>), HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy( TenantProtocolPolicy(
+15
View File
@@ -609,6 +609,21 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); 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 // inbuxa: legal hold
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() { (MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
+14
View File
@@ -110,6 +110,7 @@ pub enum GetResponseMethod {
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>), AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>), AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>), LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>), HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>), ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
TenantProtocolPolicy( TenantProtocolPolicy(
@@ -145,6 +146,7 @@ pub enum SetResponseMethod {
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>), AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<SetResponse<crate::object::inbuxa_mail_rule::MailRule>>),
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>), HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>), Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>), ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
@@ -799,6 +801,18 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
} }
// inbuxa: legal hold // inbuxa: legal hold
impl<'x> From<GetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
ResponseMethod::Get(GetResponseMethod::MailRule(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
ResponseMethod::Set(SetResponseMethod::MailRule(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> { impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self { fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
ResponseMethod::Get(GetResponseMethod::LegalHold(value)) ResponseMethod::Get(GetResponseMethod::LegalHold(value))
+24
View File
@@ -103,6 +103,16 @@ impl JmapAuthorization for AccessToken {
// inbuxa: account lock (AL-12) // inbuxa: account lock (AL-12)
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet, GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet, 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, GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
// inbuxa: legacy protocols off. It takes listeners away and // inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions // puts them back, so it takes the listener's permissions
@@ -247,6 +257,19 @@ impl JmapAuthorization for AccessToken {
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
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 // inbuxa: LH-12, exporting held data
SetRequestMethod::HoldExport(s) => validate_set( SetRequestMethod::HoldExport(s) => validate_set(
s, s,
@@ -407,6 +430,7 @@ impl JmapAuthorization for AccessToken {
| MethodObject::AccountLock | MethodObject::AccountLock
| MethodObject::LegalHold | MethodObject::LegalHold
| MethodObject::HoldExport | MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads // inbuxa: x:MaskedEmail/changes reads what /get reads
+24
View File
@@ -279,6 +279,9 @@ impl RequestHandler for Server {
SetResponseMethod::LegalHold(set_response) => { SetResponseMethod::LegalHold(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::MailRule(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::HoldExport(set_response) => { SetResponseMethod::HoldExport(set_response) => {
set_response.update_created_ids(&mut 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)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::legal_hold::get(self, *req).await?.into() 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) // inbuxa: the audit log (AU-9)
GetRequestMethod::AuditEvent(mut req) => { GetRequestMethod::AuditEvent(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -910,6 +918,22 @@ impl RequestHandler for Server {
.await? .await?
.into() .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) => { SetRequestMethod::AuditExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::export_set(self, access_token, session, *req) crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
+1
View File
@@ -429,6 +429,7 @@ impl IntermediateChangesResponse {
| MethodObject::AccountLock | MethodObject::AccountLock
| MethodObject::LegalHold | MethodObject::LegalHold
| MethodObject::HoldExport | MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy | MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
+327
View File
@@ -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<serde_json::Map<String, serde_json::Value>, SetError<P>> {
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<String, serde_json::Value>) -> Result<Rule, SetError<P>> {
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::<P>().unwrap_or(P::Name);
SetError::invalid_properties()
.with_property(property)
.with_description(invalid.reason)
})?;
Ok(rule)
}
fn forbidden(kind: Kind) -> SetError<P> {
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> {
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<MailRule>,
) -> trc::Result<GetResponse<MailRule>> {
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<Rule> = 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<SetResponse<MailRule>> {
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(&current) {
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)
}
+1
View File
@@ -10,6 +10,7 @@
pub mod access; pub mod access;
pub mod account_lock; pub mod account_lock;
pub mod legal_hold; pub mod legal_hold;
pub mod mail_rule;
pub mod hold_export; pub mod hold_export;
pub mod hold_export_api; pub mod hold_export_api;
pub mod audit; pub mod audit;
+7
View File
@@ -1748,6 +1748,13 @@ pub enum Permission {
SysLegalHoldExport = 672, SysLegalHoldExport = 672,
// inbuxa: personal-data catalog, the data inventory and compliance overview // inbuxa: personal-data catalog, the data inventory and compliance overview
SysComplianceGet = 673, SysComplianceGet = 673,
// inbuxa: DLP and mail flow rules
SysMailRuleGet = 674,
SysMailRuleUpdate = 675,
SysDlpPolicyGet = 676,
SysDlpPolicyUpdate = 677,
SysDlpReviewGet = 678,
SysDlpReviewUpdate = 679,
SysAccountGet = 219, SysAccountGet = 219,
SysAccountCreate = 220, SysAccountCreate = 220,
SysAccountUpdate = 221, SysAccountUpdate = 221,
+19 -1
View File
@@ -7091,6 +7091,12 @@ impl EnumImpl for Permission {
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate, b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
b"sysLegalHoldExport" => Permission::SysLegalHoldExport, b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
b"sysComplianceGet" => Permission::SysComplianceGet, 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"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate, b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7781,6 +7787,12 @@ impl EnumImpl for Permission {
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate", Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
Permission::SysLegalHoldExport => "sysLegalHoldExport", Permission::SysLegalHoldExport => "sysLegalHoldExport",
Permission::SysComplianceGet => "sysComplianceGet", 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::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate", Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8464,6 +8476,12 @@ impl EnumImpl for Permission {
671 => Some(Permission::SysLegalHoldUpdate), 671 => Some(Permission::SysLegalHoldUpdate),
672 => Some(Permission::SysLegalHoldExport), 672 => Some(Permission::SysLegalHoldExport),
673 => Some(Permission::SysComplianceGet), 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), 219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate), 220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate), 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 { impl serde::Serialize for Permission {
+15
View File
@@ -79,6 +79,21 @@ lockedAt = ["metadata"]
lockedBy = ["identifier"] lockedBy = ["identifier"]
delegates = ["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"] [object."inbuxa:LegalHold"]
file = "inbuxa_legal_hold.rs" file = "inbuxa_legal_hold.rs"
default = "none" default = "none"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU yF7PlBR3UBqxlabhW5zZ5WacQWsynG1wNYEiJVAl6w4
+201
View File
@@ -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<String> {
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("[email protected]");
// 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"], "[email protected]");
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::<Role>(id).await;
if role.description == "Compliance Officer" && role.member_tenant_id.is_none() {
officer_role = Some(id);
}
}
let officer = admin
.create_user_account("[email protected]", "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("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation pub mod account_lock; // inbuxa: account lock with delegation
pub mod legal_hold; // inbuxa: legal hold pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules
pub mod audit; // inbuxa: the audit log pub mod audit; // inbuxa: the audit log
pub mod authorization; pub mod authorization;
pub mod auto_reload; // inbuxa: registry writes apply at once pub mod auto_reload; // inbuxa: registry writes apply at once