DLP and mail flow rules: inbuxa:MailRule over JMAP, and its permissions
Phase 2e of the DLP and mail flow rules spec, the API half. - inbuxa:MailRule/get and /set under urn:inbuxa:jmap. Rules convert through serde, so what a client sends is the stored format. A create or change is validated whole (Rule::validate) and refused with the property at fault; id, createdBy, createdAt and updatedAt are the server's. Every change goes through the request layer's audit record. - Six permissions, ids 674-679 (enum and schema labels): mail flow rules (sysMailRuleGet/Update), DLP rules (sysDlpPolicyGet/Update) and held mail (sysDlpReviewGet/Update, for phase 3). Either kind's permission gets through the gate; the handler shows and changes each rule only with its own kind's. All server-level: a tenant is refused (settled answer 3). - Administrators get all six; the server-level Compliance Officer gets DLP rules to see and held mail to review (settled answer 4), added once to an existing server's officer role by the grant mechanism, which gains an officer audience. - Privacy catalog entry for inbuxa:MailRule. tests/src/system/mail_rules.rs: create, list in order, validation, server-set properties refused, update, kind-separated permissions for an officer, destroy, audit records.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user