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.tenant.push(permission);
|
||||
}
|
||||
// inbuxa: DLP and mail flow rules, and held mail, are the
|
||||
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
||||
// settled answer 3)
|
||||
Permission::SysMailRuleGet
|
||||
| Permission::SysMailRuleUpdate
|
||||
| Permission::SysDlpPolicyGet
|
||||
| Permission::SysDlpPolicyUpdate
|
||||
| Permission::SysDlpReviewGet
|
||||
| Permission::SysDlpReviewUpdate => {
|
||||
default.superuser.push(permission);
|
||||
}
|
||||
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||
// within their tenant
|
||||
Permission::SysAccountLockGet
|
||||
|
||||
@@ -65,6 +65,10 @@ const OFFICER: &[Permission] = &[
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysAccountLockGet,
|
||||
// dlp-and-mail-flow-rules spec, §2.8: see DLP rules, review held mail
|
||||
Permission::SysDlpPolicyGet,
|
||||
Permission::SysDlpReviewGet,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
];
|
||||
|
||||
/// What a tenant's officer holds besides [`READS`].
|
||||
@@ -113,6 +117,11 @@ fn created_key(tenant: Option<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>> {
|
||||
Ok(data
|
||||
.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.
|
||||
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
|
||||
create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ())
|
||||
create_once(registry, data, Some(tenant), tenant_role(tenant))
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Before a tenant is deleted: removes its Compliance Officer role if nobody
|
||||
/// holds it, so the role doesn't block the delete. Returns whether it did,
|
||||
/// so a delete refused for another reason can put it back.
|
||||
pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<bool> {
|
||||
pub async fn tenant_deleting(
|
||||
registry: &RegistryStore,
|
||||
data: &Store,
|
||||
tenant: Id,
|
||||
) -> trc::Result<bool> {
|
||||
let Some(role) = recorded(data, Some(tenant)).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -220,7 +235,9 @@ mod tests {
|
||||
// Beyond what any user holds for their own account
|
||||
for permission in all.into_iter().filter(|p| !user.contains(p)) {
|
||||
let name = permission.as_str();
|
||||
let holds = name.starts_with("sysLegalHold");
|
||||
// Placing holds and reviewing held mail are the officer's
|
||||
// job, not settings (settled answers 2 and 4)
|
||||
let holds = name.starts_with("sysLegalHold") || name.starts_with("sysDlpReview");
|
||||
assert!(
|
||||
!(name.ends_with("Update") && !holds)
|
||||
&& !(name.ends_with("Create") && !holds)
|
||||
@@ -249,7 +266,11 @@ mod tests {
|
||||
assert!(officer.contains(&hold));
|
||||
assert!(!tenant.contains(&hold));
|
||||
}
|
||||
for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] {
|
||||
for both in [
|
||||
Permission::SysComplianceGet,
|
||||
Permission::SysAuditGet,
|
||||
Permission::SysAccountGet,
|
||||
] {
|
||||
assert!(officer.contains(&both) && tenant.contains(&both));
|
||||
}
|
||||
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
|
||||
@@ -257,9 +278,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn records_are_per_place() {
|
||||
let ValueClass::Any(server) = created_key(None) else { panic!() };
|
||||
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() };
|
||||
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() };
|
||||
let ValueClass::Any(server) = created_key(None) else {
|
||||
panic!()
|
||||
};
|
||||
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else {
|
||||
panic!()
|
||||
};
|
||||
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(server.key, b"Pc");
|
||||
assert_ne!(a.key, b.key);
|
||||
assert!(a.key.starts_with(b"Pc"));
|
||||
|
||||
@@ -46,6 +46,21 @@ const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysComplianceGet,
|
||||
Permission::SysMailRuleGet,
|
||||
Permission::SysMailRuleUpdate,
|
||||
Permission::SysDlpPolicyGet,
|
||||
Permission::SysDlpPolicyUpdate,
|
||||
Permission::SysDlpReviewGet,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
];
|
||||
|
||||
/// Granted to the server-level Compliance Officer role once it exists:
|
||||
/// seeing DLP rules and reviewing held mail (dlp-and-mail-flow-rules spec,
|
||||
/// §2.8, settled answer 4). A new install's role has them from the start.
|
||||
const OFFICER_GRANTS: &[Permission] = &[
|
||||
Permission::SysDlpPolicyGet,
|
||||
Permission::SysDlpReviewGet,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
];
|
||||
|
||||
/// Granted to the default tenant administrator roles: reading and exporting
|
||||
@@ -65,13 +80,16 @@ const TENANT_GRANTS: &[Permission] = &[
|
||||
enum Audience {
|
||||
Admin,
|
||||
Tenant,
|
||||
Officer,
|
||||
}
|
||||
|
||||
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
||||
let mut key = b"Pg".to_vec();
|
||||
// Admin grants keep the key they were first recorded under
|
||||
if audience == Audience::Tenant {
|
||||
key.extend_from_slice(b"tenant:");
|
||||
match audience {
|
||||
Audience::Admin => {}
|
||||
Audience::Tenant => key.extend_from_slice(b"tenant:"),
|
||||
Audience::Officer => key.extend_from_slice(b"officer:"),
|
||||
}
|
||||
key.extend_from_slice(permission.as_str().as_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
@@ -82,7 +100,8 @@ fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
||||
|
||||
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
|
||||
grant(bp, Audience::Tenant, TENANT_GRANTS).await
|
||||
grant(bp, Audience::Tenant, TENANT_GRANTS).await?;
|
||||
grant(bp, Audience::Officer, OFFICER_GRANTS).await
|
||||
}
|
||||
|
||||
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
|
||||
@@ -101,10 +120,17 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
||||
if pending.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The officer role is the one the server made, if it has made it yet: a
|
||||
// new install makes it after this, with the permissions already in it
|
||||
let admin_roles: Vec<Id> = if audience == Audience::Officer {
|
||||
super::compliance_roles::server_role(&bp.data_store)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
// An administrator's default roles include the plain User role, which
|
||||
// every user also holds; only roles that are the audience's alone get it
|
||||
let admin_roles: Vec<Id> = bp
|
||||
.registry
|
||||
bp.registry
|
||||
.object::<Authentication>(Id::singleton())
|
||||
.await?
|
||||
.map(|auth| {
|
||||
@@ -118,7 +144,7 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
Audience::Tenant => (
|
||||
Audience::Tenant | Audience::Officer => (
|
||||
auth.default_tenant_role_ids.as_slice(),
|
||||
[
|
||||
auth.default_user_role_ids.as_slice(),
|
||||
@@ -133,7 +159,8 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
||||
.copied()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
};
|
||||
// Fetched by id: the registry's listing doesn't reach stored roles
|
||||
for role_id in admin_roles {
|
||||
let Some(stored) = bp
|
||||
|
||||
@@ -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_audit; // inbuxa: the audit log
|
||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
|
||||
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||
|
||||
@@ -82,6 +82,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::LegalHold(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::MailRule(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::HoldExport(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Response<'_> {
|
||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
@@ -122,6 +123,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::LegalHold(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::MailRule(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::HoldExport(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ pub enum MethodObject {
|
||||
LegalHold,
|
||||
HoldExport,
|
||||
ProtocolPolicy,
|
||||
// inbuxa: DLP and mail flow rules
|
||||
MailRule,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
|
||||
@@ -102,7 +104,8 @@ impl MethodObject {
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport => Capability::Inbuxa,
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
@@ -296,6 +299,8 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||
(MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get",
|
||||
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
|
||||
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||
@@ -448,6 +453,8 @@ impl MethodName {
|
||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||
"inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get),
|
||||
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
|
||||
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||
@@ -518,6 +525,7 @@ impl Display for MethodObject {
|
||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||
MethodObject::MailRule => "inbuxa:MailRule",
|
||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
|
||||
@@ -123,6 +123,7 @@ pub enum GetRequestMethod {
|
||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
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>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -158,6 +159,7 @@ pub enum SetRequestMethod<'x> {
|
||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
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>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
|
||||
@@ -609,6 +609,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: DLP and mail flow rules
|
||||
(MethodFunction::Get, MethodObject::MailRule) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MailRule(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::MailRule) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MailRule(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: legal hold
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||
|
||||
@@ -110,6 +110,7 @@ pub enum GetResponseMethod {
|
||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -145,6 +146,7 @@ pub enum SetResponseMethod {
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
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>>),
|
||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||
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
|
||||
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> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||
|
||||
@@ -103,6 +103,16 @@ impl JmapAuthorization for AccessToken {
|
||||
// inbuxa: account lock (AL-12)
|
||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||
// inbuxa: DLP and mail flow rules share an object; either
|
||||
// permission reaches it, and the handler shows each kind
|
||||
// only to those who may see it
|
||||
GetRequestMethod::MailRule(_) => {
|
||||
if self.has_permission(Permission::SysMailRuleGet) {
|
||||
Permission::SysMailRuleGet
|
||||
} else {
|
||||
Permission::SysDlpPolicyGet
|
||||
}
|
||||
}
|
||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
@@ -247,6 +257,19 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
),
|
||||
// inbuxa: DLP and mail flow rules: either change
|
||||
// permission gets in; the handler checks each rule's kind
|
||||
SetRequestMethod::MailRule(_) => {
|
||||
if self.has_permission(Permission::SysMailRuleUpdate)
|
||||
|| self.has_permission(Permission::SysDlpPolicyUpdate)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to change mail rules"))
|
||||
}
|
||||
}
|
||||
// inbuxa: LH-12, exporting held data
|
||||
SetRequestMethod::HoldExport(s) => validate_set(
|
||||
s,
|
||||
@@ -407,6 +430,7 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
|
||||
@@ -279,6 +279,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::LegalHold(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::MailRule(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::HoldExport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -486,6 +489,11 @@ impl RequestHandler for Server {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||
}
|
||||
// inbuxa: DLP and mail flow rules
|
||||
GetRequestMethod::MailRule(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into()
|
||||
}
|
||||
// inbuxa: the audit log (AU-9)
|
||||
GetRequestMethod::AuditEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -910,6 +918,22 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::MailRule(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone();
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::mail_rule::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::AuditExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||
|
||||
@@ -429,6 +429,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| 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 account_lock;
|
||||
pub mod legal_hold;
|
||||
pub mod mail_rule;
|
||||
pub mod hold_export;
|
||||
pub mod hold_export_api;
|
||||
pub mod audit;
|
||||
|
||||
@@ -1748,6 +1748,13 @@ pub enum Permission {
|
||||
SysLegalHoldExport = 672,
|
||||
// inbuxa: personal-data catalog, the data inventory and compliance overview
|
||||
SysComplianceGet = 673,
|
||||
// inbuxa: DLP and mail flow rules
|
||||
SysMailRuleGet = 674,
|
||||
SysMailRuleUpdate = 675,
|
||||
SysDlpPolicyGet = 676,
|
||||
SysDlpPolicyUpdate = 677,
|
||||
SysDlpReviewGet = 678,
|
||||
SysDlpReviewUpdate = 679,
|
||||
SysAccountGet = 219,
|
||||
SysAccountCreate = 220,
|
||||
SysAccountUpdate = 221,
|
||||
|
||||
@@ -7091,6 +7091,12 @@ impl EnumImpl for Permission {
|
||||
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
|
||||
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
|
||||
b"sysComplianceGet" => Permission::SysComplianceGet,
|
||||
b"sysMailRuleGet" => Permission::SysMailRuleGet,
|
||||
b"sysMailRuleUpdate" => Permission::SysMailRuleUpdate,
|
||||
b"sysDlpPolicyGet" => Permission::SysDlpPolicyGet,
|
||||
b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate,
|
||||
b"sysDlpReviewGet" => Permission::SysDlpReviewGet,
|
||||
b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate,
|
||||
b"sysAccountGet" => Permission::SysAccountGet,
|
||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||
@@ -7781,6 +7787,12 @@ impl EnumImpl for Permission {
|
||||
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
|
||||
Permission::SysLegalHoldExport => "sysLegalHoldExport",
|
||||
Permission::SysComplianceGet => "sysComplianceGet",
|
||||
Permission::SysMailRuleGet => "sysMailRuleGet",
|
||||
Permission::SysMailRuleUpdate => "sysMailRuleUpdate",
|
||||
Permission::SysDlpPolicyGet => "sysDlpPolicyGet",
|
||||
Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate",
|
||||
Permission::SysDlpReviewGet => "sysDlpReviewGet",
|
||||
Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate",
|
||||
Permission::SysAccountGet => "sysAccountGet",
|
||||
Permission::SysAccountCreate => "sysAccountCreate",
|
||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||
@@ -8464,6 +8476,12 @@ impl EnumImpl for Permission {
|
||||
671 => Some(Permission::SysLegalHoldUpdate),
|
||||
672 => Some(Permission::SysLegalHoldExport),
|
||||
673 => Some(Permission::SysComplianceGet),
|
||||
674 => Some(Permission::SysMailRuleGet),
|
||||
675 => Some(Permission::SysMailRuleUpdate),
|
||||
676 => Some(Permission::SysDlpPolicyGet),
|
||||
677 => Some(Permission::SysDlpPolicyUpdate),
|
||||
678 => Some(Permission::SysDlpReviewGet),
|
||||
679 => Some(Permission::SysDlpReviewUpdate),
|
||||
219 => Some(Permission::SysAccountGet),
|
||||
220 => Some(Permission::SysAccountCreate),
|
||||
221 => Some(Permission::SysAccountUpdate),
|
||||
@@ -8908,7 +8926,7 @@ impl EnumImpl for Permission {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 674;
|
||||
const COUNT: usize = 680;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Permission {
|
||||
|
||||
@@ -79,6 +79,21 @@ lockedAt = ["metadata"]
|
||||
lockedBy = ["identifier"]
|
||||
delegates = ["identifier"]
|
||||
|
||||
[object."inbuxa:MailRule"]
|
||||
file = "inbuxa_mail_rule.rs"
|
||||
default = "none"
|
||||
whose = ["administrator", "holder", "correspondent"]
|
||||
where = ["data-store"]
|
||||
scope = "server"
|
||||
retention = "unbounded"
|
||||
[object."inbuxa:MailRule".properties]
|
||||
name = ["content"]
|
||||
description = ["content"]
|
||||
conditions = ["contact", "content"]
|
||||
exceptions = ["contact", "content"]
|
||||
actions = ["contact", "content"]
|
||||
createdBy = ["identifier"]
|
||||
|
||||
[object."inbuxa:LegalHold"]
|
||||
file = "inbuxa_legal_hold.rs"
|
||||
default = "none"
|
||||
|
||||
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 legal_hold; // inbuxa: legal hold
|
||||
pub mod compliance; // inbuxa: the compliance roles
|
||||
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
||||
pub mod audit; // inbuxa: the audit log
|
||||
pub mod authorization;
|
||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||
|
||||
Reference in New Issue
Block a user