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

Phase 2e of the DLP and mail flow rules spec, the API half.

- inbuxa:MailRule/get and /set under urn:inbuxa:jmap. Rules convert
  through serde, so what a client sends is the stored format. A create
  or change is validated whole (Rule::validate) and refused with the
  property at fault; id, createdBy, createdAt and updatedAt are the
  server's. Every change goes through the request layer's audit record.
- Six permissions, ids 674-679 (enum and schema labels): mail flow rules
  (sysMailRuleGet/Update), DLP rules (sysDlpPolicyGet/Update) and held
  mail (sysDlpReviewGet/Update, for phase 3). Either kind's permission
  gets through the gate; the handler shows and changes each rule only
  with its own kind's. All server-level: a tenant is refused (settled
  answer 3).
- Administrators get all six; the server-level Compliance Officer gets
  DLP rules to see and held mail to review (settled answer 4), added
  once to an existing server's officer role by the grant mechanism,
  which gains an officer audience.
- Privacy catalog entry for inbuxa:MailRule.

tests/src/system/mail_rules.rs: create, list in order, validation,
server-set properties refused, update, kind-separated permissions for
an officer, destroy, audit records.
This commit is contained in:
2026-09-28 17:29:35 -07:00
parent c8280de9c3
commit 8afaee7d21
23 changed files with 994 additions and 45 deletions
+11
View File
@@ -296,6 +296,17 @@ impl Default for DefaultPermissions {
default.superuser.push(permission);
default.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
+34 -7
View File
@@ -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,39 +120,47 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
if pending.is_empty() {
return Ok(());
}
// An administrator's default roles include the plain User role, which
// every user also holds; only roles that are the audience's alone get it
let admin_roles: Vec<Id> = bp
.registry
.object::<Authentication>(Id::singleton())
.await?
.map(|auth| {
let (own, shared) = match audience {
Audience::Admin => (
auth.default_admin_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
auth.default_tenant_role_ids.as_slice(),
]
.concat(),
),
Audience::Tenant => (
auth.default_tenant_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
// The officer role is the one the server made, if it has made it yet: a
// new install makes it after this, with the permissions already in it
let admin_roles: Vec<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
bp.registry
.object::<Authentication>(Id::singleton())
.await?
.map(|auth| {
let (own, shared) = match audience {
Audience::Admin => (
auth.default_admin_role_ids.as_slice(),
]
.concat(),
),
};
own.iter()
.filter(|id| !shared.contains(id))
.copied()
.collect()
})
.unwrap_or_default();
[
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
auth.default_tenant_role_ids.as_slice(),
]
.concat(),
),
Audience::Tenant | Audience::Officer => (
auth.default_tenant_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(),
auth.default_group_role_ids.as_slice(),
auth.default_admin_role_ids.as_slice(),
]
.concat(),
),
};
own.iter()
.filter(|id| !shared.contains(id))
.copied()
.collect()
})
.unwrap_or_default()
};
// Fetched by id: the registry's listing doesn't reach stored roles
for role_id in admin_roles {
let Some(stored) = bp