DLP and mail flow rules: rules, engine, and inbuxa:MailRule over JMAP #103
@@ -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
|
||||||
|
|||||||
@@ -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,39 +120,47 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
|||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// An administrator's default roles include the plain User role, which
|
// The officer role is the one the server made, if it has made it yet: a
|
||||||
// every user also holds; only roles that are the audience's alone get it
|
// new install makes it after this, with the permissions already in it
|
||||||
let admin_roles: Vec<Id> = bp
|
let admin_roles: Vec<Id> = if audience == Audience::Officer {
|
||||||
.registry
|
super::compliance_roles::server_role(&bp.data_store)
|
||||||
.object::<Authentication>(Id::singleton())
|
.await?
|
||||||
.await?
|
.into_iter()
|
||||||
.map(|auth| {
|
.collect()
|
||||||
let (own, shared) = match audience {
|
} else {
|
||||||
Audience::Admin => (
|
// An administrator's default roles include the plain User role, which
|
||||||
auth.default_admin_role_ids.as_slice(),
|
// every user also holds; only roles that are the audience's alone get it
|
||||||
[
|
bp.registry
|
||||||
auth.default_user_role_ids.as_slice(),
|
.object::<Authentication>(Id::singleton())
|
||||||
auth.default_group_role_ids.as_slice(),
|
.await?
|
||||||
auth.default_tenant_role_ids.as_slice(),
|
.map(|auth| {
|
||||||
]
|
let (own, shared) = match audience {
|
||||||
.concat(),
|
Audience::Admin => (
|
||||||
),
|
|
||||||
Audience::Tenant => (
|
|
||||||
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(),
|
auth.default_admin_role_ids.as_slice(),
|
||||||
]
|
[
|
||||||
.concat(),
|
auth.default_user_role_ids.as_slice(),
|
||||||
),
|
auth.default_group_role_ids.as_slice(),
|
||||||
};
|
auth.default_tenant_role_ids.as_slice(),
|
||||||
own.iter()
|
]
|
||||||
.filter(|id| !shared.contains(id))
|
.concat(),
|
||||||
.copied()
|
),
|
||||||
.collect()
|
Audience::Tenant | Audience::Officer => (
|
||||||
})
|
auth.default_tenant_role_ids.as_slice(),
|
||||||
.unwrap_or_default();
|
[
|
||||||
|
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
|
// 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,53 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The compiled rules, kept per node so a message doesn't read the store.
|
||||||
|
//! A change made on this node applies at once; one made on another node
|
||||||
|
//! within [`TTL`], when the copy here is next refreshed.
|
||||||
|
|
||||||
|
use super::{engine::Compiled, rules};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::Store;
|
||||||
|
|
||||||
|
/// How long a node keeps its copy before reading the rules again.
|
||||||
|
pub const TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
static CACHE: RwLock<Option<(Instant, Arc<Compiled>)>> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Forgets the copy, so the next message reads the rules again.
|
||||||
|
pub fn invalidate() {
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled rules, compiled. A rule that no longer compiles is left out
|
||||||
|
/// and reported, once per refresh.
|
||||||
|
pub async fn compiled(data: &Store) -> trc::Result<Arc<Compiled>> {
|
||||||
|
if let Ok(cache) = CACHE.read()
|
||||||
|
&& let Some((at, compiled)) = cache.as_ref()
|
||||||
|
&& at.elapsed() < TTL
|
||||||
|
{
|
||||||
|
return Ok(compiled.clone());
|
||||||
|
}
|
||||||
|
let (compiled, skipped) = Compiled::new(&rules::all(data).await?);
|
||||||
|
for (id, reason) in skipped {
|
||||||
|
trc::event!(
|
||||||
|
Store(trc::StoreEvent::DataCorruption),
|
||||||
|
Id = u64::from(id),
|
||||||
|
Reason = reason,
|
||||||
|
Details = "Mail rule skipped: it no longer compiles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let compiled = Arc::new(compiled);
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = Some((Instant::now(), compiled.clone()));
|
||||||
|
}
|
||||||
|
Ok(compiled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Evaluating rules against a message (§2.1–§2.4). Rules are compiled once,
|
||||||
|
//! when they change: word lists become automata, patterns regexes. A message
|
||||||
|
//! is then checked against every enabled rule in order; each detector runs
|
||||||
|
//! at most once per message, and only when some rule asks for it.
|
||||||
|
//!
|
||||||
|
//! Pure: the caller parses the message, extracts attachment text
|
||||||
|
//! ([`super::extract`]) and knows the sender's groups and tenant. What comes
|
||||||
|
//! back is which rules matched, with each detector's count, and what DLP
|
||||||
|
//! decided; the matched text itself never leaves here (§2.7).
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
detectors::{self, Findings},
|
||||||
|
extract::Extracted,
|
||||||
|
rules::{Action, Condition, Direction, Kind, Rule},
|
||||||
|
words::{Pattern, WordList},
|
||||||
|
};
|
||||||
|
use ahash::AHashMap;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// Who sent a message, and to whom.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Envelope<'a> {
|
||||||
|
/// Outgoing (an authenticated sender) or incoming.
|
||||||
|
pub outgoing: bool,
|
||||||
|
pub sender: &'a str,
|
||||||
|
pub sender_groups: &'a [u32],
|
||||||
|
pub sender_tenant: Option<u32>,
|
||||||
|
pub recipients: Vec<Recipient<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Recipient<'a> {
|
||||||
|
pub address: &'a str,
|
||||||
|
/// At a domain this server hosts.
|
||||||
|
pub local: bool,
|
||||||
|
pub groups: &'a [u32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Attachment<'a> {
|
||||||
|
pub name: Option<&'a str>,
|
||||||
|
/// Declared type, or detected where the caller knows better.
|
||||||
|
pub content_type: &'a str,
|
||||||
|
pub size: u64,
|
||||||
|
pub extracted: Extracted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What rules look at.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Content<'a> {
|
||||||
|
pub subject: &'a str,
|
||||||
|
/// Each text and HTML part, as text.
|
||||||
|
pub bodies: Vec<Cow<'a, str>>,
|
||||||
|
pub headers: Vec<(&'a str, &'a str)>,
|
||||||
|
pub attachments: Vec<Attachment<'a>>,
|
||||||
|
pub size: u64,
|
||||||
|
/// Text past the inspection limit wasn't read.
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Content<'_> {
|
||||||
|
fn texts(&self) -> impl Iterator<Item = &str> {
|
||||||
|
std::iter::once(self.subject)
|
||||||
|
.chain(self.bodies.iter().map(|b| b.as_ref()))
|
||||||
|
.chain(self.attachments.iter().filter_map(|a| match &a.extracted {
|
||||||
|
Extracted::Text(text) => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cant_be_inspected(&self) -> bool {
|
||||||
|
self.truncated
|
||||||
|
|| self
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a.extracted, Extracted::NotInspectable(_)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Check {
|
||||||
|
Plain(Condition),
|
||||||
|
Words(WordList, u32),
|
||||||
|
Pattern(Pattern, u32),
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
contains: Option<String>,
|
||||||
|
matches: Option<Pattern>,
|
||||||
|
},
|
||||||
|
AttachmentName(Pattern),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompiledRule {
|
||||||
|
rule: Rule,
|
||||||
|
conditions: Vec<Check>,
|
||||||
|
exceptions: Vec<Check>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled rules, ready to run.
|
||||||
|
pub struct Compiled {
|
||||||
|
rules: Vec<CompiledRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule reference, for notices and the audit record.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuleRef {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub notice: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Match {
|
||||||
|
pub rule_id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
/// Each detector (or `words`, `pattern`) that counted, and its count.
|
||||||
|
pub counts: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub matched: Vec<Match>,
|
||||||
|
pub blocks: Vec<RuleRef>,
|
||||||
|
pub holds: Vec<(RuleRef, bool)>,
|
||||||
|
pub warns: Vec<RuleRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What DLP decided, strictest first (§2.4).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Pass,
|
||||||
|
Block(Vec<RuleRef>),
|
||||||
|
Hold {
|
||||||
|
rules: Vec<RuleRef>,
|
||||||
|
notify_sender: bool,
|
||||||
|
},
|
||||||
|
Warn(Vec<RuleRef>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
/// Block beats hold beats warn. An override (§2.5) answers the warnings
|
||||||
|
/// only: a block or hold still applies.
|
||||||
|
pub fn decision(&self, overridden: bool) -> Decision {
|
||||||
|
if !self.blocks.is_empty() {
|
||||||
|
Decision::Block(self.blocks.clone())
|
||||||
|
} else if !self.holds.is_empty() {
|
||||||
|
Decision::Hold {
|
||||||
|
rules: self.holds.iter().map(|(r, _)| r.clone()).collect(),
|
||||||
|
notify_sender: self.holds.iter().any(|(_, notify)| *notify),
|
||||||
|
}
|
||||||
|
} else if !self.warns.is_empty() && !overridden {
|
||||||
|
Decision::Warn(self.warns.clone())
|
||||||
|
} else {
|
||||||
|
Decision::Pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_check(condition: &Condition) -> Result<Check, String> {
|
||||||
|
Ok(match condition {
|
||||||
|
Condition::Words { words, at_least } => Check::Words(WordList::new(words)?, *at_least),
|
||||||
|
Condition::Pattern { pattern, at_least } => {
|
||||||
|
Check::Pattern(Pattern::new(pattern)?, *at_least)
|
||||||
|
}
|
||||||
|
Condition::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => Check::Header {
|
||||||
|
name: name.to_ascii_lowercase(),
|
||||||
|
contains: contains.as_ref().map(|c| c.to_lowercase()),
|
||||||
|
matches: matches.as_deref().map(Pattern::new).transpose()?,
|
||||||
|
},
|
||||||
|
Condition::AttachmentName { pattern } => Check::AttachmentName(Pattern::new(pattern)?),
|
||||||
|
other => Check::Plain(other.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compiled {
|
||||||
|
/// Compiles the enabled rules; one that no longer compiles (a detector
|
||||||
|
/// renamed since it was saved) is skipped and named in the second list.
|
||||||
|
pub fn new(rules: &[Rule]) -> (Self, Vec<(u32, String)>) {
|
||||||
|
let mut compiled = Vec::new();
|
||||||
|
let mut skipped = Vec::new();
|
||||||
|
for rule in rules.iter().filter(|r| r.enabled) {
|
||||||
|
let result = rule.validate().map_err(|e| e.reason).and_then(|_| {
|
||||||
|
Ok(CompiledRule {
|
||||||
|
rule: rule.clone(),
|
||||||
|
conditions: rule
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.map(compile_check)
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
exceptions: rule
|
||||||
|
.exceptions
|
||||||
|
.iter()
|
||||||
|
.map(compile_check)
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(c) => compiled.push(c),
|
||||||
|
Err(reason) => skipped.push((rule.id, reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compiled.sort_by_key(|c| (c.rule.priority, c.rule.id));
|
||||||
|
(Self { rules: compiled }, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.rules.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any rule could apply to mail going this way, so a caller can
|
||||||
|
/// skip parsing when none can.
|
||||||
|
pub fn applies_to(&self, outgoing: bool) -> bool {
|
||||||
|
self.rules
|
||||||
|
.iter()
|
||||||
|
.any(|c| direction_matches(c.rule.direction, outgoing))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(&self, envelope: &Envelope<'_>, content: &Content<'_>) -> Outcome {
|
||||||
|
let mut state = State {
|
||||||
|
content,
|
||||||
|
detected: AHashMap::new(),
|
||||||
|
};
|
||||||
|
let mut outcome = Outcome::default();
|
||||||
|
for compiled in &self.rules {
|
||||||
|
let rule = &compiled.rule;
|
||||||
|
if !direction_matches(rule.direction, envelope.outgoing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut counts = Vec::new();
|
||||||
|
let all_match = compiled
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.all(|check| state.check(check, envelope, &mut counts));
|
||||||
|
if !all_match {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ignored = Vec::new();
|
||||||
|
if compiled
|
||||||
|
.exceptions
|
||||||
|
.iter()
|
||||||
|
.any(|check| state.check(check, envelope, &mut ignored))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for action in &rule.actions {
|
||||||
|
let reference = |notice: &str| RuleRef {
|
||||||
|
id: rule.id,
|
||||||
|
name: rule.name.clone(),
|
||||||
|
notice: notice.to_string(),
|
||||||
|
};
|
||||||
|
match action {
|
||||||
|
Action::Block { notice } => outcome.blocks.push(reference(notice)),
|
||||||
|
Action::Hold {
|
||||||
|
notice,
|
||||||
|
notify_sender,
|
||||||
|
} => outcome.holds.push((reference(notice), *notify_sender)),
|
||||||
|
Action::Warn { notice } => outcome.warns.push(reference(notice)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome.matched.push(Match {
|
||||||
|
rule_id: rule.id,
|
||||||
|
name: rule.name.clone(),
|
||||||
|
kind: rule.kind,
|
||||||
|
actions: rule.actions.clone(),
|
||||||
|
counts,
|
||||||
|
});
|
||||||
|
if rule.stop_processing {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direction_matches(direction: Direction, outgoing: bool) -> bool {
|
||||||
|
match direction {
|
||||||
|
Direction::Any => true,
|
||||||
|
Direction::Outgoing => outgoing,
|
||||||
|
Direction::Incoming => !outgoing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_of(address: &str) -> &str {
|
||||||
|
address.rsplit_once('@').map_or("", |(_, d)| d)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_list(value: &str, list: &[String]) -> bool {
|
||||||
|
list.iter().any(|v| v.eq_ignore_ascii_case(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State<'c, 'a> {
|
||||||
|
content: &'c Content<'a>,
|
||||||
|
/// Each detector's count, run once per message.
|
||||||
|
detected: AHashMap<&'static str, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State<'_, '_> {
|
||||||
|
fn detector_count(&mut self, id: &str) -> usize {
|
||||||
|
let Some(detector) = detectors::by_id(id) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if let Some(count) = self.detected.get(detector.id) {
|
||||||
|
return *count;
|
||||||
|
}
|
||||||
|
let mut findings = Findings::default();
|
||||||
|
for text in self.content.texts() {
|
||||||
|
detector.find(text, &mut findings);
|
||||||
|
}
|
||||||
|
self.detected.insert(detector.id, findings.len());
|
||||||
|
findings.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(
|
||||||
|
&mut self,
|
||||||
|
check: &Check,
|
||||||
|
envelope: &Envelope<'_>,
|
||||||
|
counts: &mut Vec<(String, usize)>,
|
||||||
|
) -> bool {
|
||||||
|
let content = self.content;
|
||||||
|
match check {
|
||||||
|
Check::Words(list, at_least) => {
|
||||||
|
let n: usize = content.texts().map(|t| list.count(t)).sum();
|
||||||
|
counts.push(("words".into(), n));
|
||||||
|
n >= *at_least as usize
|
||||||
|
}
|
||||||
|
Check::Pattern(pattern, at_least) => {
|
||||||
|
let n: usize = content.texts().map(|t| pattern.count(t)).sum();
|
||||||
|
counts.push(("pattern".into(), n));
|
||||||
|
n >= *at_least as usize
|
||||||
|
}
|
||||||
|
Check::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => content
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.filter(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||||
|
.any(|(_, value)| match (contains, matches) {
|
||||||
|
(Some(needle), _) => value.to_lowercase().contains(needle.as_str()),
|
||||||
|
(_, Some(pattern)) => pattern.count(value) > 0,
|
||||||
|
_ => true,
|
||||||
|
}),
|
||||||
|
Check::AttachmentName(pattern) => content
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.name.is_some_and(|n| pattern.count(n) > 0)),
|
||||||
|
Check::Plain(condition) => match condition {
|
||||||
|
Condition::SenderAddress { addresses } => in_list(envelope.sender, addresses),
|
||||||
|
Condition::SenderDomain { domains } => in_list(domain_of(envelope.sender), domains),
|
||||||
|
Condition::SenderGroup { groups } => {
|
||||||
|
envelope.sender_groups.iter().any(|g| groups.contains(g))
|
||||||
|
}
|
||||||
|
Condition::SenderTenant { tenants } => {
|
||||||
|
envelope.sender_tenant.is_some_and(|t| tenants.contains(&t))
|
||||||
|
}
|
||||||
|
Condition::RecipientAddress { addresses } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| in_list(r.address, addresses)),
|
||||||
|
Condition::RecipientDomain { domains } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| in_list(domain_of(r.address), domains)),
|
||||||
|
Condition::RecipientGroup { groups } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.groups.iter().any(|g| groups.contains(g))),
|
||||||
|
Condition::RecipientOutside => envelope.recipients.iter().any(|r| !r.local),
|
||||||
|
Condition::AttachmentType { types } => content.attachments.iter().any(|a| {
|
||||||
|
let ct = a.content_type.to_ascii_lowercase();
|
||||||
|
types
|
||||||
|
.iter()
|
||||||
|
.any(|t| ct.starts_with(&t.to_ascii_lowercase()))
|
||||||
|
}),
|
||||||
|
Condition::AttachmentExtension { extensions } => {
|
||||||
|
content.attachments.iter().any(|a| {
|
||||||
|
a.name
|
||||||
|
.and_then(|n| n.rsplit_once('.'))
|
||||||
|
.is_some_and(|(_, ext)| {
|
||||||
|
extensions
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Condition::AttachmentSizeOver { bytes } => {
|
||||||
|
content.attachments.iter().any(|a| a.size > *bytes)
|
||||||
|
}
|
||||||
|
Condition::AttachmentCountOver { count } => {
|
||||||
|
content.attachments.len() > *count as usize
|
||||||
|
}
|
||||||
|
Condition::CantBeInspected => content.cant_be_inspected(),
|
||||||
|
Condition::MessageSizeOver { bytes } => content.size > *bytes,
|
||||||
|
Condition::Detected { detectors } => {
|
||||||
|
let mut any = false;
|
||||||
|
for d in detectors {
|
||||||
|
let n = self.detector_count(&d.id);
|
||||||
|
counts.push((d.id.clone(), n));
|
||||||
|
any |= n >= d.at_least as usize;
|
||||||
|
}
|
||||||
|
any
|
||||||
|
}
|
||||||
|
// Compiled into their own checks
|
||||||
|
Condition::Words { .. }
|
||||||
|
| Condition::Pattern { .. }
|
||||||
|
| Condition::Header { .. }
|
||||||
|
| Condition::AttachmentName { .. } => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mailflow::{
|
||||||
|
extract::Why,
|
||||||
|
rules::{DetectorMin, Position},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn rule(id: u32, kind: Kind, conditions: Vec<Condition>, action: Action) -> Rule {
|
||||||
|
Rule {
|
||||||
|
id,
|
||||||
|
name: format!("rule {id}"),
|
||||||
|
description: String::new(),
|
||||||
|
kind,
|
||||||
|
enabled: true,
|
||||||
|
priority: id as i32,
|
||||||
|
direction: if kind == Kind::Dlp {
|
||||||
|
Direction::Outgoing
|
||||||
|
} else {
|
||||||
|
Direction::Any
|
||||||
|
},
|
||||||
|
conditions,
|
||||||
|
exceptions: vec![],
|
||||||
|
actions: vec![action],
|
||||||
|
stop_processing: false,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope(outside: bool) -> Envelope<'static> {
|
||||||
|
Envelope {
|
||||||
|
outgoing: true,
|
||||||
|
sender: "[email protected]",
|
||||||
|
sender_groups: &[7],
|
||||||
|
sender_tenant: None,
|
||||||
|
recipients: vec![Recipient {
|
||||||
|
address: if outside {
|
||||||
|
"[email protected]"
|
||||||
|
} else {
|
||||||
|
"[email protected]"
|
||||||
|
},
|
||||||
|
local: !outside,
|
||||||
|
groups: &[],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cards(n: usize) -> Content<'static> {
|
||||||
|
let body: String = [
|
||||||
|
"4242 4242 4242 4242",
|
||||||
|
"5555-5555-5555-4444",
|
||||||
|
"378282246310005",
|
||||||
|
"6011111111111117",
|
||||||
|
"3566002020360505",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.take(n)
|
||||||
|
.map(|c| format!("card {c}\n"))
|
||||||
|
.collect();
|
||||||
|
Content {
|
||||||
|
subject: "Numbers",
|
||||||
|
bodies: vec![body.into()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn five_cards_outside(action: Action) -> Rule {
|
||||||
|
rule(
|
||||||
|
1,
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![
|
||||||
|
Condition::RecipientOutside,
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "payment-card".into(),
|
||||||
|
at_least: 5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detector_threshold_and_recipients() {
|
||||||
|
let (rules, skipped) = Compiled::new(&[five_cards_outside(Action::Hold {
|
||||||
|
notice: "Held".into(),
|
||||||
|
notify_sender: true,
|
||||||
|
})]);
|
||||||
|
assert!(skipped.is_empty());
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.matched[0].counts,
|
||||||
|
vec![("payment-card".to_string(), 5)]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
outcome.decision(false),
|
||||||
|
Decision::Hold {
|
||||||
|
notify_sender: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// Four cards, or everyone inside: nothing
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&envelope(true), &cards(4)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&envelope(false), &cards(5)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strictest_wins_and_override_answers_warnings_only() {
|
||||||
|
let warn = five_cards_outside(Action::Warn {
|
||||||
|
notice: "Sure?".into(),
|
||||||
|
});
|
||||||
|
let mut block = five_cards_outside(Action::Block {
|
||||||
|
notice: "No".into(),
|
||||||
|
});
|
||||||
|
block.id = 2;
|
||||||
|
let (rules, _) = Compiled::new(&[warn.clone(), block]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert!(matches!(outcome.decision(true), Decision::Block(_)));
|
||||||
|
let (rules, _) = Compiled::new(&[warn]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert!(matches!(outcome.decision(false), Decision::Warn(ref w) if w[0].notice == "Sure?"));
|
||||||
|
assert_eq!(outcome.decision(true), Decision::Pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exceptions_order_and_stop_processing() {
|
||||||
|
let disclaimer = |id| {
|
||||||
|
rule(
|
||||||
|
id,
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Condition::RecipientOutside],
|
||||||
|
Action::AddDisclaimer {
|
||||||
|
text: "t".into(),
|
||||||
|
html: None,
|
||||||
|
position: Position::Bottom,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut first = disclaimer(1);
|
||||||
|
first.stop_processing = true;
|
||||||
|
let (rules, _) = Compiled::new(&[disclaimer(2), first.clone()]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.matched
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rule_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![1]
|
||||||
|
);
|
||||||
|
|
||||||
|
first.stop_processing = false;
|
||||||
|
first.exceptions = vec![Condition::SenderGroup { groups: vec![7] }];
|
||||||
|
let (rules, _) = Compiled::new(&[disclaimer(2), first]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.matched
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rule_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_conditions() {
|
||||||
|
let content = Content {
|
||||||
|
subject: "Project Falcon",
|
||||||
|
bodies: vec!["see attached".into()],
|
||||||
|
headers: vec![("X-Class", "Internal only")],
|
||||||
|
attachments: vec![
|
||||||
|
Attachment {
|
||||||
|
name: Some("plan.docx"),
|
||||||
|
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
size: 40_000,
|
||||||
|
extracted: Extracted::Text("IBAN GB29 NWBK 6016 1331 9268 19".into()),
|
||||||
|
},
|
||||||
|
Attachment {
|
||||||
|
name: Some("scan.pdf"),
|
||||||
|
content_type: "application/pdf",
|
||||||
|
size: 900_000,
|
||||||
|
extracted: Extracted::NotInspectable(Why::Pdf),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
size: 1_000_000,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
let block = || Action::Block { notice: "n".into() };
|
||||||
|
let checks = [
|
||||||
|
(
|
||||||
|
Condition::Words {
|
||||||
|
words: vec!["project falcon".into()],
|
||||||
|
at_least: 1,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::Header {
|
||||||
|
name: "x-class".into(),
|
||||||
|
contains: Some("internal".into()),
|
||||||
|
matches: None,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::AttachmentExtension {
|
||||||
|
extensions: vec![".PDF".into()],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::AttachmentType {
|
||||||
|
types: vec!["image/".into()],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(Condition::AttachmentSizeOver { bytes: 500_000 }, true),
|
||||||
|
(Condition::AttachmentCountOver { count: 2 }, false),
|
||||||
|
(Condition::CantBeInspected, true),
|
||||||
|
(Condition::MessageSizeOver { bytes: 2_000_000 }, false),
|
||||||
|
(
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "iban".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::SenderDomain {
|
||||||
|
domains: vec!["EXAMPLE.com".into()],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (condition, expected) in checks {
|
||||||
|
let (rules, skipped) =
|
||||||
|
Compiled::new(&[rule(1, Kind::Dlp, vec![condition.clone()], block())]);
|
||||||
|
assert!(skipped.is_empty(), "{condition:?}");
|
||||||
|
let matched = !rules.evaluate(&envelope(true), &content).matched.is_empty();
|
||||||
|
assert_eq!(matched, expected, "{condition:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direction_and_disabled_rules() {
|
||||||
|
let mut r = five_cards_outside(Action::Block { notice: "n".into() });
|
||||||
|
let (rules, _) = Compiled::new(std::slice::from_ref(&r));
|
||||||
|
assert!(rules.applies_to(true) && !rules.applies_to(false));
|
||||||
|
let mut incoming = envelope(true);
|
||||||
|
incoming.outgoing = false;
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&incoming, &cards(5)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
r.enabled = false;
|
||||||
|
assert!(Compiled::new(&[r]).0.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,18 +6,24 @@
|
|||||||
|
|
||||||
//! Data loss prevention and mail flow rules (dlp-and-mail-flow-rules spec).
|
//! Data loss prevention and mail flow rules (dlp-and-mail-flow-rules spec).
|
||||||
//!
|
//!
|
||||||
//! Pure functions over text and attachment bytes, so everything here is
|
//! Mostly pure functions over text and attachment bytes, unit-tested
|
||||||
//! unit-tested without a server:
|
//! without a server:
|
||||||
//!
|
//!
|
||||||
//! - [`detectors`]: find identifiers in text (payment cards, IBANs,
|
//! - [`detectors`]: find identifiers in text (payment cards, IBANs,
|
||||||
//! national ID numbers, keys), each by its published format and check
|
//! national ID numbers, keys), each by its published format and check
|
||||||
//! (§2.3);
|
//! (§2.3);
|
||||||
//! - [`words`]: an organization's own word lists and patterns;
|
//! - [`words`]: an organization's own word lists and patterns;
|
||||||
//! - [`extract`]: the text of an attachment, or why it can't be read.
|
//! - [`extract`]: the text of an attachment, or why it can't be read;
|
||||||
|
//! - [`rules`]: what a rule is, its checks, and where rules are kept;
|
||||||
|
//! - [`engine`]: rules compiled and run against a message;
|
||||||
|
//! - [`cache`]: each node's compiled copy.
|
||||||
//!
|
//!
|
||||||
//! Nothing here writes what it finds anywhere: callers get counts, and the
|
//! Nothing here writes what it finds anywhere: callers get counts, and the
|
||||||
//! matched text never leaves the evaluation (§2.7).
|
//! matched text never leaves the evaluation (§2.7).
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
pub mod detectors;
|
pub mod detectors;
|
||||||
|
pub mod engine;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
|
pub mod rules;
|
||||||
pub mod words;
|
pub mod words;
|
||||||
|
|||||||
@@ -0,0 +1,717 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Mail flow rules and DLP rules (dlp-and-mail-flow-rules spec, §2.2–§2.4):
|
||||||
|
//! what a rule is, what makes one valid, and where it's kept.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`), never in the
|
||||||
|
//! registry, so an upstream schema import never touches them. Every key
|
||||||
|
//! starts with `R`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `r` + rule id (u32): the rule, as JSON.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian. There are few rules, so they're read whole.
|
||||||
|
|
||||||
|
use super::{detectors, words};
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'R';
|
||||||
|
const KIND_RULE: u8 = b'r';
|
||||||
|
const CREATE_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
|
/// Longest text a rule may carry (a notice, a disclaimer), in bytes.
|
||||||
|
const MAX_TEXT: usize = 16 * 1024;
|
||||||
|
/// Most entries in one list (words, addresses, domains).
|
||||||
|
const MAX_LIST: usize = 5_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Kind {
|
||||||
|
Dlp,
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Direction {
|
||||||
|
/// Mail an authenticated sender submits, over SMTP or JMAP.
|
||||||
|
Outgoing,
|
||||||
|
/// Everything else the server accepts.
|
||||||
|
Incoming,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Position {
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A detector and the least it must find.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DetectorMin {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub at_least: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Condition {
|
||||||
|
SenderAddress {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
SenderDomain {
|
||||||
|
domains: Vec<String>,
|
||||||
|
},
|
||||||
|
SenderGroup {
|
||||||
|
groups: Vec<u32>,
|
||||||
|
},
|
||||||
|
SenderTenant {
|
||||||
|
tenants: Vec<u32>,
|
||||||
|
},
|
||||||
|
/// Any recipient is one of these.
|
||||||
|
RecipientAddress {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
RecipientDomain {
|
||||||
|
domains: Vec<String>,
|
||||||
|
},
|
||||||
|
RecipientGroup {
|
||||||
|
groups: Vec<u32>,
|
||||||
|
},
|
||||||
|
/// Any recipient isn't at a domain this server hosts.
|
||||||
|
RecipientOutside,
|
||||||
|
/// Words or phrases in the subject, body or readable attachments.
|
||||||
|
Words {
|
||||||
|
words: Vec<String>,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
at_least: u32,
|
||||||
|
},
|
||||||
|
/// The organization's regular expression, in the same places.
|
||||||
|
Pattern {
|
||||||
|
pattern: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
at_least: u32,
|
||||||
|
},
|
||||||
|
/// A header exists, or its value contains or matches.
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
contains: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
matches: Option<String>,
|
||||||
|
},
|
||||||
|
/// An attachment's declared or detected type starts with one of these.
|
||||||
|
AttachmentType {
|
||||||
|
types: Vec<String>,
|
||||||
|
},
|
||||||
|
AttachmentExtension {
|
||||||
|
extensions: Vec<String>,
|
||||||
|
},
|
||||||
|
AttachmentName {
|
||||||
|
pattern: String,
|
||||||
|
},
|
||||||
|
AttachmentSizeOver {
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
AttachmentCountOver {
|
||||||
|
count: u32,
|
||||||
|
},
|
||||||
|
/// An attachment is encrypted, a PDF, a legacy Office file, an archive
|
||||||
|
/// inside an archive, or past the inspection limit.
|
||||||
|
CantBeInspected,
|
||||||
|
MessageSizeOver {
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
/// Any of these detectors finds at least its minimum (DLP rules only).
|
||||||
|
Detected {
|
||||||
|
detectors: Vec<DetectorMin>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Action {
|
||||||
|
// Transport actions
|
||||||
|
AddDisclaimer {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
html: Option<String>,
|
||||||
|
position: Position,
|
||||||
|
},
|
||||||
|
AddHeader {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
RemoveHeader {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
PrefixSubject {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
AddRecipient {
|
||||||
|
address: String,
|
||||||
|
},
|
||||||
|
Redirect {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
Refuse {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
queue: String,
|
||||||
|
},
|
||||||
|
// DLP actions
|
||||||
|
Block {
|
||||||
|
notice: String,
|
||||||
|
},
|
||||||
|
Warn {
|
||||||
|
notice: String,
|
||||||
|
},
|
||||||
|
Hold {
|
||||||
|
notice: String,
|
||||||
|
#[serde(default)]
|
||||||
|
notify_sender: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn is_dlp(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Action::Block { .. } | Action::Warn { .. } | Action::Hold { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Rule {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
#[serde(default = "enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: i32,
|
||||||
|
pub direction: Direction,
|
||||||
|
#[serde(default)]
|
||||||
|
pub conditions: Vec<Condition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exceptions: Vec<Condition>,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stop_processing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a rule can't be saved: the property at fault, and a sentence.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Invalid {
|
||||||
|
pub property: &'static str,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
|
||||||
|
Invalid {
|
||||||
|
property,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rule {
|
||||||
|
/// Everything that can be checked without the rest of the server: the
|
||||||
|
/// shape (§2.2, §2.4), the detectors, word lists and patterns.
|
||||||
|
pub fn validate(&self) -> Result<(), Invalid> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(invalid("name", "A rule needs a name."));
|
||||||
|
}
|
||||||
|
if self.name.len() > 200 || self.description.len() > MAX_TEXT {
|
||||||
|
return Err(invalid("name", "The name or description is too long."));
|
||||||
|
}
|
||||||
|
if self.actions.is_empty() {
|
||||||
|
return Err(invalid("actions", "A rule needs something to do."));
|
||||||
|
}
|
||||||
|
let dlp_actions = self.actions.iter().filter(|a| a.is_dlp()).count();
|
||||||
|
match self.kind {
|
||||||
|
Kind::Dlp => {
|
||||||
|
if self.direction != Direction::Outgoing {
|
||||||
|
return Err(invalid("direction", "DLP rules check outgoing mail only."));
|
||||||
|
}
|
||||||
|
if dlp_actions != 1 || self.actions.len() != 1 {
|
||||||
|
return Err(invalid(
|
||||||
|
"actions",
|
||||||
|
"A DLP rule has exactly one action: block, warn or hold.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Transport => {
|
||||||
|
if dlp_actions > 0 {
|
||||||
|
return Err(invalid(
|
||||||
|
"actions",
|
||||||
|
"Block, warn and hold belong to DLP rules.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.chain(&self.exceptions)
|
||||||
|
.any(|c| matches!(c, Condition::Detected { .. }))
|
||||||
|
{
|
||||||
|
return Err(invalid("conditions", "Detectors belong to DLP rules."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (property, list) in [
|
||||||
|
("conditions", &self.conditions),
|
||||||
|
("exceptions", &self.exceptions),
|
||||||
|
] {
|
||||||
|
for condition in list {
|
||||||
|
validate_condition(condition).map_err(|reason| invalid(property, reason))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for action in &self.actions {
|
||||||
|
validate_action(action).map_err(|reason| invalid("actions", reason))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonempty_list<T>(list: &[T], what: &str) -> Result<(), String> {
|
||||||
|
if list.is_empty() {
|
||||||
|
Err(format!("The {what} list is empty."))
|
||||||
|
} else if list.len() > MAX_LIST {
|
||||||
|
Err(format!(
|
||||||
|
"The {what} list is longer than {MAX_LIST} entries."
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_name(name: &str) -> Result<(), String> {
|
||||||
|
if !name.is_empty()
|
||||||
|
&& name.len() <= 100
|
||||||
|
&& name.bytes().all(|b| b.is_ascii_graphic() && b != b':')
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("\"{name}\" isn't a header name."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: &str, what: &str) -> Result<(), String> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
Err(format!("The {what} is empty."))
|
||||||
|
} else if value.len() > MAX_TEXT {
|
||||||
|
Err(format!("The {what} is longer than {MAX_TEXT} bytes."))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_condition(condition: &Condition) -> Result<(), String> {
|
||||||
|
match condition {
|
||||||
|
Condition::SenderAddress { addresses } | Condition::RecipientAddress { addresses } => {
|
||||||
|
nonempty_list(addresses, "address")
|
||||||
|
}
|
||||||
|
Condition::SenderDomain { domains } | Condition::RecipientDomain { domains } => {
|
||||||
|
nonempty_list(domains, "domain")
|
||||||
|
}
|
||||||
|
Condition::SenderGroup { groups } | Condition::RecipientGroup { groups } => {
|
||||||
|
nonempty_list(groups, "group")
|
||||||
|
}
|
||||||
|
Condition::SenderTenant { tenants } => nonempty_list(tenants, "tenant"),
|
||||||
|
Condition::Words { words, at_least } => {
|
||||||
|
nonempty_list(words, "word")?;
|
||||||
|
if *at_least == 0 {
|
||||||
|
return Err("The least number of words must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
words::WordList::new(words).map(|_| ())
|
||||||
|
}
|
||||||
|
Condition::Pattern { pattern, at_least } => {
|
||||||
|
if *at_least == 0 {
|
||||||
|
return Err("The least number of matches must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
words::Pattern::new(pattern).map(|_| ())
|
||||||
|
}
|
||||||
|
Condition::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => {
|
||||||
|
header_name(name)?;
|
||||||
|
if let Some(pattern) = matches {
|
||||||
|
words::Pattern::new(pattern)?;
|
||||||
|
}
|
||||||
|
if contains.is_some() && matches.is_some() {
|
||||||
|
return Err("A header condition is either contains or matches.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Condition::AttachmentType { types } => nonempty_list(types, "type"),
|
||||||
|
Condition::AttachmentExtension { extensions } => nonempty_list(extensions, "extension"),
|
||||||
|
Condition::AttachmentName { pattern } => words::Pattern::new(pattern).map(|_| ()),
|
||||||
|
Condition::Detected { detectors } => {
|
||||||
|
nonempty_list(detectors, "detector")?;
|
||||||
|
for d in detectors {
|
||||||
|
if detectors::by_id(&d.id).is_none() {
|
||||||
|
return Err(format!("There is no detector \"{}\".", d.id));
|
||||||
|
}
|
||||||
|
if d.at_least == 0 {
|
||||||
|
return Err("A detector's least count must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Condition::RecipientOutside
|
||||||
|
| Condition::AttachmentSizeOver { .. }
|
||||||
|
| Condition::AttachmentCountOver { .. }
|
||||||
|
| Condition::CantBeInspected
|
||||||
|
| Condition::MessageSizeOver { .. } => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_action(action: &Action) -> Result<(), String> {
|
||||||
|
match action {
|
||||||
|
Action::AddDisclaimer { text: t, html, .. } => {
|
||||||
|
text(t, "disclaimer")?;
|
||||||
|
html.as_deref()
|
||||||
|
.map_or(Ok(()), |h| text(h, "disclaimer's HTML"))
|
||||||
|
}
|
||||||
|
Action::AddHeader { name, value } => {
|
||||||
|
header_name(name)?;
|
||||||
|
if value.len() > 998 || value.contains(['\r', '\n']) {
|
||||||
|
Err("A header value is one line of at most 998 characters.".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::RemoveHeader { name } => header_name(name),
|
||||||
|
Action::PrefixSubject { text: t } => text(t, "subject prefix"),
|
||||||
|
Action::AddRecipient { address } => {
|
||||||
|
if address.contains('@') {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("\"{address}\" isn't an address."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Redirect { addresses } => {
|
||||||
|
nonempty_list(addresses, "address")?;
|
||||||
|
match addresses.iter().find(|a| !a.contains('@')) {
|
||||||
|
Some(a) => Err(format!("\"{a}\" isn't an address.")),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Refuse { text: t } => text(t, "refusal text"),
|
||||||
|
Action::Route { queue } => text(queue, "queue"),
|
||||||
|
Action::Block { notice } | Action::Warn { notice } | Action::Hold { notice, .. } => {
|
||||||
|
text(notice, "notice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage --------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize mail rule")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid mail rule")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_RULE);
|
||||||
|
key.extend_from_slice(&id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||||
|
ValueKey::from(class(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Rule>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Rule>>(key(id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(rule)| rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every rule, in the order they run: by priority, then oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Rule>> {
|
||||||
|
let mut rules = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(rule)) = Json::<Rule>::deserialize(value) {
|
||||||
|
rules.push(rule);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
rules.sort_by_key(|rule| (rule.priority, rule.id));
|
||||||
|
Ok(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a new rule under the next free id, which it returns. Two nodes
|
||||||
|
/// creating rules at once can't take the same id: the key must be absent.
|
||||||
|
pub async fn create(data: &Store, rule: &Rule) -> trc::Result<u32> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let id = all(data).await?.iter().map(|r| r.id).max().unwrap_or(0) + 1;
|
||||||
|
let stored = Rule { id, ..rule.clone() };
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(class(id), AssertValue::None);
|
||||||
|
batch.set(class(id), Json(&stored).serialize()?);
|
||||||
|
match data.write(batch.build_all()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
super::cache::invalidate();
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
if attempt < CREATE_ATTEMPTS
|
||||||
|
&& matches!(
|
||||||
|
err.as_ref(),
|
||||||
|
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||||
|
) => {}
|
||||||
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a stored rule (same id).
|
||||||
|
pub async fn update(data: &Store, rule: &Rule) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(rule.id), Json(rule).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
super::cache::invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(class(id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
super::cache::invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rule(kind: Kind, actions: Vec<Action>) -> Rule {
|
||||||
|
Rule {
|
||||||
|
id: 0,
|
||||||
|
name: "Cards outside".into(),
|
||||||
|
description: String::new(),
|
||||||
|
kind,
|
||||||
|
enabled: true,
|
||||||
|
priority: 0,
|
||||||
|
direction: Direction::Outgoing,
|
||||||
|
conditions: vec![Condition::RecipientOutside],
|
||||||
|
exceptions: vec![],
|
||||||
|
actions,
|
||||||
|
stop_processing: false,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_format() {
|
||||||
|
let json = r#"{"name":"Cards","kind":"dlp","direction":"outgoing",
|
||||||
|
"conditions":[{"type":"recipientOutside"},{"type":"detected","detectors":[{"id":"payment-card","atLeast":5}]}],
|
||||||
|
"actions":[{"type":"hold","notice":"Held for review","notifySender":true}]}"#;
|
||||||
|
let parsed: Rule = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.conditions[1],
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "payment-card".into(),
|
||||||
|
at_least: 5
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.actions[0],
|
||||||
|
Action::Hold {
|
||||||
|
notice: "Held for review".into(),
|
||||||
|
notify_sender: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(parsed.validate().is_ok());
|
||||||
|
let back = serde_json::to_value(&parsed).unwrap();
|
||||||
|
assert_eq!(back["actions"][0]["notifySender"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dlp_rules_have_one_dlp_action_on_outgoing_mail() {
|
||||||
|
let block = Action::Block {
|
||||||
|
notice: "No.".into(),
|
||||||
|
};
|
||||||
|
assert!(rule(Kind::Dlp, vec![block.clone()]).validate().is_ok());
|
||||||
|
let two = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![
|
||||||
|
block.clone(),
|
||||||
|
Action::Warn {
|
||||||
|
notice: "Hm.".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(two.validate().unwrap_err().property, "actions");
|
||||||
|
let mixed = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![block.clone(), Action::PrefixSubject { text: "[x]".into() }],
|
||||||
|
);
|
||||||
|
assert_eq!(mixed.validate().unwrap_err().property, "actions");
|
||||||
|
let mut inbound = rule(Kind::Dlp, vec![block.clone()]);
|
||||||
|
inbound.direction = Direction::Incoming;
|
||||||
|
assert_eq!(inbound.validate().unwrap_err().property, "direction");
|
||||||
|
assert_eq!(
|
||||||
|
rule(Kind::Transport, vec![block])
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.property,
|
||||||
|
"actions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conditions_and_actions_are_checked() {
|
||||||
|
let disclaimer = Action::AddDisclaimer {
|
||||||
|
text: "Sent from Example Co.".into(),
|
||||||
|
html: None,
|
||||||
|
position: Position::Bottom,
|
||||||
|
};
|
||||||
|
let mut r = rule(Kind::Transport, vec![disclaimer]);
|
||||||
|
assert!(r.validate().is_ok());
|
||||||
|
r.conditions.push(Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "iban".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert_eq!(r.validate().unwrap_err().property, "conditions");
|
||||||
|
|
||||||
|
let mut r = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![Action::Block {
|
||||||
|
notice: "No.".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
r.conditions = vec![Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "nope".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
assert!(r.validate().unwrap_err().reason.contains("nope"));
|
||||||
|
r.conditions = vec![Condition::Pattern {
|
||||||
|
pattern: "(".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}];
|
||||||
|
assert!(r.validate().is_err());
|
||||||
|
r.conditions = vec![Condition::Words {
|
||||||
|
words: vec![],
|
||||||
|
at_least: 1,
|
||||||
|
}];
|
||||||
|
assert!(r.validate().is_err());
|
||||||
|
r.exceptions = vec![Condition::Header {
|
||||||
|
name: "X-Bad: yes".into(),
|
||||||
|
contains: None,
|
||||||
|
matches: None,
|
||||||
|
}];
|
||||||
|
r.conditions = vec![];
|
||||||
|
assert_eq!(r.validate().unwrap_err().property, "exceptions");
|
||||||
|
|
||||||
|
let header = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::AddHeader {
|
||||||
|
name: "X-Tag".into(),
|
||||||
|
value: "a\r\nBcc: x@y".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(header.validate().is_err());
|
||||||
|
let redirect = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::Redirect {
|
||||||
|
addresses: vec!["nobody".into()],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(redirect.validate().is_err());
|
||||||
|
let mut unnamed = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::RemoveHeader {
|
||||||
|
name: "X-Tag".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
unnamed.name = " ".into();
|
||||||
|
assert_eq!(unnamed.validate().unwrap_err().property, "name");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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!(),
|
||||||
|
|||||||
@@ -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(¤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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU
|
yF7PlBR3UBqxlabhW5zZ5WacQWsynG1wNYEiJVAl6w4
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user