Give IMAP, POP3 and ManageSieve a switch each
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s

The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).

legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.

SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.

Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.

Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
This commit is contained in:
2026-09-27 23:12:32 -07:00
parent 7ba9ec9fa0
commit 8e9cedbe97
15 changed files with 930 additions and 199 deletions
+25 -15
View File
@@ -46,10 +46,10 @@ pub struct Network {
#[derive(Clone)]
pub struct NetworkInfo {
pub pacc: Pacc,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
/// served while legacy protocols are off (legacy-protocols LP-7).
pub pacc_jmap_only: Pacc,
/// inbuxa: the document once per combination of legacy protocols off,
/// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
/// protocol); index 0 is the full document.
pub pacc: Vec<Pacc>,
pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>,
}
@@ -333,16 +333,27 @@ impl Network {
})
.unwrap()
};
// inbuxa: legacy-protocols LP-7
let pacc_jmap_only = {
let mut pacc = pacc.clone();
pacc.protocols.imap = None;
pacc.protocols.pop3 = None;
pacc.protocols.smtp = None;
pacc.protocols.managesieve = None;
split(&pacc)
};
let pacc = split(&pacc);
// inbuxa: legacy-protocols LP-7, one document per combination of
// protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
// submission.
let pacc = (0..16usize)
.map(|off| {
let mut pacc = pacc.clone();
if off & 1 != 0 {
pacc.protocols.imap = None;
}
if off & 2 != 0 {
pacc.protocols.pop3 = None;
}
if off & 4 != 0 {
pacc.protocols.managesieve = None;
}
if off & 8 != 0 {
pacc.protocols.smtp = None;
}
split(&pacc)
})
.collect();
let mut network = Network {
node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(),
@@ -358,7 +369,6 @@ impl Network {
mxs: system.mail_exchangers.into_iter().collect(),
services: system.services,
pacc,
pacc_jmap_only,
},
};
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use crate::{Server, manager::application::Resource};
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::Event;
@@ -59,11 +59,11 @@ impl Server {
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = match emailaddress.rsplit_once('@') {
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
None => self.legacy_protocols_off_for("").await?,
Some((_, domain)) => self.legacy_off_for(domain).await?,
None => self.legacy_off_for("").await?,
};
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
if legacy_off.service(protocol) {
continue;
}
let (protocol, ports) = match protocol {
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol;
use std::fmt::Write;
use utils::url_params::UrlParams;
@@ -31,7 +31,7 @@ impl Server {
};
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain).await?;
let legacy_off = self.legacy_off_for(domain).await?;
// Build XML response
let mut config = String::with_capacity(1024);
@@ -45,7 +45,7 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>"
);
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
if legacy_off.service(protocol) {
continue;
}
let (protocol, tag, ports) = match protocol {
+7 -14
View File
@@ -6,11 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose};
use dns_update::{
@@ -41,7 +37,7 @@ impl Server {
let default_host = network.server_name.as_str();
let domain_name = domain.name.as_str();
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
let legacy_off = self.legacy_off_for(domain_name).await?;
let domain_name_suffix = format!(".{domain_name}");
for record_type in record_types {
@@ -205,7 +201,7 @@ impl Server {
// name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) {
if legacy_off.service(protocol) {
for (service_name, _) in services {
records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."),
@@ -307,8 +303,8 @@ impl Server {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays.
if legacy_off
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
&& legacy_off.service(protocol)
{
continue;
}
@@ -418,11 +414,8 @@ impl Server {
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7, LP-14a
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
&self.core.network.info.pacc_jmap_only
} else {
&self.core.network.info.pacc
};
let off = self.legacy_off_for(domain_name).await?;
let pacc = &self.core.network.info.pacc[off.index()];
self.get_directory_for_domain(domain_name)
.await
.caused_by(trc::location!())
+191 -63
View File
@@ -35,8 +35,8 @@ use directory::Credentials;
use inbuxa_features::security::{
legacy_use::{self, LegacyUse},
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
tenant_protocol_policy,
protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
};
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId};
@@ -97,40 +97,48 @@ impl Server {
// this, and a /set that omitted it must not lose the listeners still
// waiting to come back.
let previous = self.protocol_policy().await?;
policy.saved_listeners = previous.saved_listeners;
policy.saved_listeners = previous.saved_listeners.clone();
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = changed_by;
policy.normalize();
if policy.legacy_protocols.is_disabled() {
self.close_legacy_listeners(&mut policy, &mut change).await?;
} else {
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
}
// Each protocol on its own switch: close what is off now, and put
// back what was saved for a protocol that is on again. Either may
// happen in one change, when one protocol goes off as another comes
// back.
self.close_legacy_listeners(&mut policy, &mut change)
.await?;
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
protocol_policy::set(&self.core.storage.data, &policy).await?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch
// a switch is reported. A /set that changed nothing -- every switch
// already where it was asked to be, nothing to close or reopen -- is
// not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
(&change.closed, "closed")
} else {
(&change.reopened, "reopened")
};
let mut before = previous;
before.normalize();
if before.off() != policy.off() || !change.is_empty() {
// The closed first, then the reopened; `Details` says which.
let moved = change
.closed
.iter()
.chain(change.reopened.iter())
.map(|l| l.id.clone());
trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server",
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
Value = switches_value(&policy),
AccountId = policy.changed_by.clone(),
Details = direction,
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
Details = if change.closed.is_empty() {
"reopened"
} else if change.reopened.is_empty() {
"closed"
} else {
"closed and reopened"
},
ListenerId = listener_names(moved),
// Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names(
change
@@ -165,21 +173,27 @@ impl Server {
Ok(())
}
/// Puts back every saved listener and starts it again (LP-5).
/// Puts back every saved listener whose protocol is on again, and starts
/// it (LP-5). The rest stay saved.
async fn reopen_legacy_listeners(
&self,
policy: &mut ProtocolPolicy,
change: &mut PolicyChange,
) -> trc::Result<()> {
if policy.saved_listeners.is_empty() {
let (wanted, still_closed): (Vec<_>, Vec<_>) = std::mem::take(&mut policy.saved_listeners)
.into_iter()
.partition(|saved| !policy.closes(&saved.protocol, &saved.ports));
policy.saved_listeners = still_closed;
if wanted.is_empty() {
return Ok(());
}
let saved = std::mem::take(&mut policy.saved_listeners);
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
// A listener that could not be put back stays saved for another try.
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
policy
.saved_listeners
.extend(failed.iter().map(|(listener, _)| listener.clone()));
change.failed = failed;
if !restored.is_empty() {
@@ -254,6 +268,17 @@ impl Server {
}
}
/// The switches as an event value: `disabled` or `enabled` when all three
/// agree, otherwise which are off, such as `pop3 disabled` (LP-8).
pub fn switches_value(policy: &impl Switches) -> String {
let off = policy.off();
match off.len() {
0 => "enabled".to_string(),
n if n == SWITCHED.len() => "disabled".to_string(),
_ => format!("{} disabled", off.join(", ")),
}
}
/// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
@@ -395,15 +420,18 @@ impl Server {
credentials: &Credentials,
) -> trc::Result<()> {
let domain = domain_of(credentials);
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
let server = self.protocol_policy().await?;
if server.is_off(protocol.as_str()) {
return Err(protocol.refused(RefusalScope::Server, domain));
}
if let Some(name) = &domain
&& let Some(domain) = self.domain(name).await?
&& let Some(tenant_id) = domain.id_tenant
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
let tenant = self.tenant_protocol_policy(tenant_id).await?;
if tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()).is_some() {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
}
}
Ok(())
}
@@ -422,10 +450,16 @@ impl Server {
protocol: LegacyProtocol,
access_token: &AccessToken,
) -> trc::Result<()> {
if let Some(tenant_id) = access_token.tenant_id()
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
if let Some(tenant_id) = access_token.tenant_id() {
let server = self.protocol_policy().await?;
let tenant = self.tenant_protocol_policy(tenant_id).await?;
match tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()) {
Some(OffBy::Server) => return Err(protocol.refused(RefusalScope::Server, None)),
Some(OffBy::Tenant) => {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
}
None => {}
}
}
if let Err(err) = legacy_use::record(
&self.core.storage.data,
@@ -463,31 +497,96 @@ impl Server {
Ok(recent)
}
/// Whether legacy protocols are off for this account: the stricter of the
/// server's switch and its tenant's. What the JMAP session tells the
/// Which legacy protocols are off for this account: each the stricter of
/// the server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
/// say why a mail app won't connect (LP-19).
pub async fn legacy_protocols_off_for_account(
pub async fn legacy_off_for_account(
&self,
access_token: &AccessToken,
) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match access_token.tenant_id() {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
) -> trc::Result<LegacyOff> {
let server = self.protocol_policy().await?;
let tenant = match access_token.tenant_id() {
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
None => None,
};
Ok(LegacyOff::of(&server, tenant.as_ref()))
}
/// A tenant's switches, or all on when it has never set them (LP-10).
pub async fn tenant_protocol_policy(
&self,
tenant_id: u32,
) -> trc::Result<TenantProtocolPolicy> {
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
}
}
/// Which legacy protocols are off, for one account or one domain: the server's
/// switches and the tenant's together. Submission is off only when all three
/// are.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LegacyOff {
pub imap: bool,
pub pop3: bool,
pub manage_sieve: bool,
pub submission: bool,
}
impl LegacyOff {
pub fn of(server: &ProtocolPolicy, tenant: Option<&TenantProtocolPolicy>) -> Self {
let off = |protocol| tenant_protocol_policy::off_by(server, tenant, protocol).is_some();
LegacyOff {
imap: off("imap"),
pop3: off("pop3"),
manage_sieve: off("manageSieve"),
submission: off(SUBMISSION),
}
}
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
Ok(
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
.await?
.legacy_protocols
.is_disabled(),
)
/// Whether this configured service must not be offered (LP-7). SMTP here
/// is submission; inbound mail is never a configured service.
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
match protocol {
ServiceProtocol::Imap => self.imap,
ServiceProtocol::Pop3 => self.pop3,
ServiceProtocol::Managesieve => self.manage_sieve,
ServiceProtocol::Smtp => self.submission,
_ => false,
}
}
/// Whether anything is off.
pub fn any(&self) -> bool {
self.imap || self.pop3 || self.manage_sieve || self.submission
}
/// Whether everything is off: the kill-all's effect.
pub fn all(&self) -> bool {
self.imap && self.pop3 && self.manage_sieve && self.submission
}
/// An index for answers prepared once per combination (the PACC
/// document): one bit per protocol.
pub fn index(&self) -> usize {
(self.imap as usize)
| (self.pop3 as usize) << 1
| (self.manage_sieve as usize) << 2
| (self.submission as usize) << 3
}
/// The protocols that are still allowed, by JMAP name, for the session.
pub fn allowed(&self) -> Vec<&'static str> {
[
("imap", self.imap),
("pop3", self.pop3),
("manageSieve", self.manage_sieve),
(SUBMISSION, self.submission),
]
.into_iter()
.filter(|(_, off)| !off)
.map(|(name, _)| name)
.collect()
}
}
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
}
impl Server {
/// Whether legacy services are off for this domain, for the answers that
/// Which legacy services are off for this domain, for the answers that
/// must stop offering them: off for the whole server (LP-7), or for the
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
/// reads it. A name that is no domain here answers for the server alone.
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match self.domain(domain_name).await? {
pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
let server = self.protocol_policy().await?;
let tenant = match self.domain(domain_name).await? {
Some(domain) => match domain.id_tenant {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
None => None,
},
None => Ok(false),
}
None => None,
};
Ok(LegacyOff::of(&server, tenant.as_ref()))
}
}
@@ -619,6 +717,36 @@ mod tests {
}
}
#[test]
fn what_is_off_for_one_account_or_domain() {
use inbuxa_features::security::protocol_policy::LegacyProtocols;
let mut server = ProtocolPolicy::default();
server.set("pop3", LegacyProtocols::Disabled);
let mut tenant = TenantProtocolPolicy::default();
tenant.set("manageSieve", LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, Some(&tenant));
assert!(off.pop3 && off.manage_sieve && !off.imap && !off.submission);
assert!(off.service(&ServiceProtocol::Pop3));
assert!(!off.service(&ServiceProtocol::Imap));
assert!(
!off.service(&ServiceProtocol::Smtp),
"sending is still offered"
);
assert!(!off.service(&ServiceProtocol::Jmap));
assert_eq!(off.allowed(), vec!["imap", "submission"]);
assert!(off.any() && !off.all());
let off = LegacyOff::of(&server, None);
assert_eq!(off.index(), 0b0010);
server.set_all(LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, None);
assert!(off.all());
assert_eq!(off.index(), 0b1111);
assert!(off.allowed().is_empty());
}
#[test]
fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
+229 -11
View File
@@ -8,6 +8,17 @@
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
//!
//! Each mail-app protocol has its own switch (legacy-protocols spec,
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
//! `disabled` exactly when all three are off. A policy stored before the
//! per-protocol switches has only `legacyProtocols`, and reads as all three
//! at that value.
//!
//! SMTP submission has no switch of its own here: sign-in over it is refused
//! only when all three are off, as it was by the single switch (LP-6), so
//! turning off one protocol never stops a mail app sending.
//!
//! This module is the fact, not the act. It holds what the operator chose and
//! which listeners were taken away to honour it. Closing sockets belongs to
//! `common`, which owns the listener registry, and removing the listener
@@ -59,12 +70,30 @@ pub struct SavedListener {
pub object: serde_json::Value,
}
/// The server-wide switch.
/// The protocols with a switch of their own, as the schema and JMAP spell
/// them.
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
pub const SUBMISSION: &str = "submission";
/// The server-wide switches.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProtocolPolicy {
/// The switch itself.
/// The kill-all: `disabled` exactly when all three protocols are off,
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
/// the per-protocol switches, it is the value of all three.
pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// With `disabled`, also close SMTP submission (LP-3). The inbound
/// listener on port 25 is never closed, whatever this says.
pub close_submission: bool,
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
fn default() -> Self {
ProtocolPolicy {
legacy_protocols: LegacyProtocols::Enabled,
imap: None,
pop3: None,
manage_sieve: None,
close_submission: true,
saved_listeners: Vec::new(),
changed_at: None,
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[
"legacyProtocols",
"imap",
"pop3",
"manageSieve",
"closeSubmission",
"savedListeners",
"changedAt",
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
.any(|locked| locked.eq_ignore_ascii_case(protocol))
}
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the switch
/// closes. A listener bound to port 25 is inbound whatever its name, and
/// any other SMTP listener counts as submission (LP-3).
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
if !self.legacy_protocols.is_disabled() {
return false;
/// The switch fields, by protocol name.
pub trait Switches {
/// The kill-all, which an unset per-protocol switch reads as.
fn all(&self) -> LegacyProtocols;
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
fn set_all_field(&mut self, value: LegacyProtocols);
/// One protocol's switch. `submission` follows the kill-all: it is off
/// only when all three are. Anything else has no switch and is on.
fn switch(&self, protocol: &str) -> LegacyProtocols {
if protocol == SUBMISSION {
return if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
}
match self.slot(protocol) {
Some(value) => value.unwrap_or(self.all()),
None => LegacyProtocols::Enabled,
}
}
/// Whether this protocol is off.
fn is_off(&self, protocol: &str) -> bool {
self.switch(protocol).is_disabled()
}
/// Whether all three protocols are off.
fn all_off(&self) -> bool {
SWITCHED.iter().all(|protocol| {
self.slot(protocol)
.and_then(|value| *value)
.unwrap_or(self.all())
.is_disabled()
})
}
/// Sets one protocol's switch; false if it has none.
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
match self.slot_mut(protocol) {
Some(slot) => {
*slot = Some(value);
true
}
None => false,
}
}
/// The kill-all: all three at once.
fn set_all(&mut self, value: LegacyProtocols) {
for protocol in SWITCHED {
self.set(protocol, value);
}
self.set_all_field(value);
}
/// Writes out every switch and derives the kill-all from them, so what is
/// stored and shown never depends on how it was reached.
fn normalize(&mut self) {
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
for (protocol, value) in SWITCHED.iter().zip(values) {
self.set(protocol, value);
}
let all = if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
self.set_all_field(all);
}
/// The protocols that are off.
fn off(&self) -> Vec<&'static str> {
SWITCHED
.iter()
.copied()
.filter(|p| self.is_off(p))
.collect()
}
}
macro_rules! switches {
($t:ty) => {
impl Switches for $t {
fn all(&self) -> LegacyProtocols {
self.legacy_protocols
}
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&self.imap),
"pop3" => Some(&self.pop3),
"manageSieve" => Some(&self.manage_sieve),
_ => None,
}
}
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&mut self.imap),
"pop3" => Some(&mut self.pop3),
"manageSieve" => Some(&mut self.manage_sieve),
_ => None,
}
}
fn set_all_field(&mut self, value: LegacyProtocols) {
self.legacy_protocols = value;
}
}
};
}
pub(crate) use switches;
switches!(ProtocolPolicy);
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the
/// switches close. A listener bound to port 25 is inbound whatever its
/// name, and any other SMTP listener counts as submission (LP-3), closed
/// only with all three off and `closeSubmission`.
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
// The lock is checked first and answers for every caller, so no
// request phrasing can reach past it (LP-21).
if is_locked(protocol) {
return false;
}
if LEGACY_PROTOCOLS.contains(&protocol) {
return true;
return self.is_off(protocol);
}
protocol.eq_ignore_ascii_case("smtp")
&& self.all_off()
&& self.close_submission
&& !ports.contains(&INBOUND_SMTP_PORT)
}
@@ -258,7 +407,10 @@ mod tests {
"an unset closeSubmission reads as the default, true"
);
let json = serde_json::to_value(&policy).unwrap();
// As shown: normalized, every switch written out.
let mut shown = policy.clone();
shown.normalize();
let json = serde_json::to_value(&shown).unwrap();
for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}");
}
@@ -410,6 +562,72 @@ mod tests {
);
}
/// A policy stored before the per-protocol switches reads as all three
/// at its one value.
#[test]
fn an_old_policy_reads_as_all_three() {
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.all_off() && old.is_off(SUBMISSION));
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
}
/// One protocol off closes only its listeners, and leaves sending alone.
#[test]
fn one_protocol_off() {
let mut policy = ProtocolPolicy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
assert!(policy.closes("pop3", &[995]));
assert!(!policy.closes("imap", &[993]));
assert!(!policy.closes("manageSieve", &[4190]));
assert!(!policy.is_off(SUBMISSION), "sending goes on");
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
assert_eq!(policy.off(), vec!["pop3"]);
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["pop3"], "disabled");
assert_eq!(json["imap"], "enabled");
}
/// Turning the three off one at a time is the kill-all, and the kill-all
/// back on turns all three on.
#[test]
fn the_kill_all_is_all_three() {
let mut policy = ProtocolPolicy::default();
for p in SWITCHED {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
assert!(policy.legacy_protocols.is_disabled());
assert!(policy.is_off(SUBMISSION));
policy.set_all(LegacyProtocols::Enabled);
policy.normalize();
assert!(policy.off().is_empty());
assert!(!policy.legacy_protocols.is_disabled());
// The kill-all then one back on: no longer all off.
policy.set_all(LegacyProtocols::Disabled);
policy.set("imap", LegacyProtocols::Enabled);
policy.normalize();
assert!(!policy.legacy_protocols.is_disabled());
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
}
/// Protocols without a switch are never off.
#[test]
fn unswitched_protocols_are_on() {
let policy = disabled();
for p in ["smtp", "http", "lmtp", "jmap"] {
assert!(!policy.is_off(p), "{p}");
}
}
/// A saved listener with no id is refused, naming the property.
#[test]
fn a_nameless_saved_listener_is_refused() {
@@ -9,12 +9,18 @@
//! the tenant id in the fork's subspace; a tenant with nothing stored has
//! legacy protocols on.
//!
//! A tenant has the same three switches as the server (IMAP, POP3,
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
//! every tenant whatever the tenant's own switch says.
//!
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
//! refuses sign-in on the tenant's domains, and keeps client configuration
//! for them from offering what's refused. That is all it is: one fact per
//! tenant, easy to turn back, touching no listener, role or permission.
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
use crate::security::protocol_policy::{
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
@@ -26,24 +32,92 @@ use trc::AddContext;
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TenantProtocolPolicy {
/// The switch itself.
/// The kill-all, as on the server's policy.
pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// When it last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>,
/// The account that last changed it.
pub changed_by: Option<String>,
}
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
switches!(TenantProtocolPolicy);
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
///
/// A tenant can always turn legacy protocols off for itself. It can turn
/// them back on only while the server has them on: server off means off for
/// everyone.
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
so they can't be turned back on for one organization.",
)
/// A tenant can always turn a protocol off for itself. It can turn one on
/// only while the server has it on: server off means off for everyone.
/// `turned_on` is what the request sets to `enabled`, by protocol name.
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
let blocked: Vec<&str> = turned_on
.iter()
.copied()
.filter(|protocol| server.is_off(protocol))
.collect();
(!blocked.is_empty()).then(|| {
format!(
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
back on for one organization.",
names(&blocked),
if blocked.len() == 1 { "it" } else { "they" }
)
})
}
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
fn names(protocols: &[&str]) -> String {
let named: Vec<&str> = protocols
.iter()
.map(|p| match *p {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
})
.collect();
let list = match named.as_slice() {
[one] => one.to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
[] => String::new(),
};
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
}
/// Whose switch turns a protocol off, if any.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffBy {
Server,
Tenant,
}
/// Whether this protocol is off for an account or domain, and by whose
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
/// is off when all three protocols are, counting both switches together.
pub fn off_by(
server: &ProtocolPolicy,
tenant: Option<&TenantProtocolPolicy>,
protocol: &str,
) -> Option<OffBy> {
if server.is_off(protocol) {
return Some(OffBy::Server);
}
let tenant = tenant?;
let off = if protocol == SUBMISSION {
SWITCHED
.iter()
.all(|p| server.is_off(p) || tenant.is_off(p))
} else {
tenant.is_off(protocol)
};
off.then_some(OffBy::Tenant)
}
fn key(tenant_id: u32) -> ValueClass {
@@ -115,6 +189,15 @@ mod tests {
}
}
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
let mut policy = TenantProtocolPolicy::default();
for p in protocols {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
policy
}
#[test]
fn a_tenant_starts_with_legacy_protocols_on() {
assert!(
@@ -127,20 +210,59 @@ mod tests {
#[test]
fn a_tenant_can_always_turn_them_off() {
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
assert_eq!(refusal(&server(s), &[]), None);
}
}
#[test]
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
// LP-9, acceptance test 9.
assert_eq!(
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
None
);
let why =
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
assert!(
why.starts_with("IMAP, POP3 and ManageSieve are off"),
"{why}"
);
}
#[test]
fn a_tenant_can_turn_on_what_the_server_allows() {
// The server has only POP3 off: IMAP may come back, POP3 may not.
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
assert_eq!(refusal(&s, &["imap"]), None);
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
assert!(why.starts_with("POP3 is off"), "{why}");
}
#[test]
fn whose_switch_turns_a_protocol_off() {
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
let t = tenant_off(&["imap"]);
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
assert_eq!(off_by(&s, None, "imap"), None);
// Sending goes on while any protocol is still allowed.
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
// Between them, all three off: submission follows (LP-6, LP-10).
let t = tenant_off(&["imap", "manageSieve"]);
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
assert_eq!(
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
Some(OffBy::Server)
);
}
#[test]
fn an_old_tenant_policy_reads_as_all_three() {
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.is_off(SUBMISSION));
}
#[test]
@@ -158,6 +280,7 @@ mod tests {
legacy_protocols: LegacyProtocols::Disabled,
changed_at: Some(1),
changed_by: Some("b".into()),
..Default::default()
};
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(back, policy);
@@ -23,8 +23,13 @@ pub struct ProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyProperty {
Id,
/// The switch: `enabled` or `disabled`.
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
/// Whether submission closes with it. Forced false while SMTP is locked.
CloseSubmission,
/// Server-set: the listeners taken away, for LP-5.
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
match self {
ProtocolPolicyProperty::Id => "id",
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
ProtocolPolicyProperty::Imap => "imap",
ProtocolPolicyProperty::Pop3 => "pop3",
ProtocolPolicyProperty::ManageSieve => "manageSieve",
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt",
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
hashify::tiny_map!(value.as_bytes(),
b"id" => ProtocolPolicyProperty::Id,
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
b"imap" => ProtocolPolicyProperty::Imap,
b"pop3" => ProtocolPolicyProperty::Pop3,
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
Id,
/// Server-set: the tenant this is the switch of.
TenantId,
/// The switch: `enabled` or `disabled`.
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
ChangedAt,
ChangedBy,
/// Server-set: who signed in over a legacy protocol in the last 30
@@ -48,6 +53,9 @@ impl Property for TenantProtocolPolicyProperty {
TenantProtocolPolicyProperty::Id => "id",
TenantProtocolPolicyProperty::TenantId => "tenantId",
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
TenantProtocolPolicyProperty::Imap => "imap",
TenantProtocolPolicyProperty::Pop3 => "pop3",
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
b"id" => TenantProtocolPolicyProperty::Id,
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
b"imap" => TenantProtocolPolicyProperty::Imap,
b"pop3" => TenantProtocolPolicyProperty::Pop3,
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
/// (legacy-protocols spec, Interfaces; LP-19).
#[serde(rename(serialize = "legacyProtocols"))]
pub legacy_protocols: &'static str,
/// The legacy protocols still allowed for the principal, each the
/// stricter of the two switches: `imap`, `pop3`, `manageSieve`,
/// `submission` (legacy-protocols spec, one switch per protocol).
#[serde(rename(serialize = "legacyAllowed"))]
pub legacy_allowed: Vec<&'static str>,
/// Whether the principal may use "Explain this" now: it holds
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
/// spec, EX-1 to EX-4).
+7 -2
View File
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
Capability::Inbuxa,
Capabilities::Empty(EmptyCapabilities::default()),
);
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
// per protocol. `legacyProtocols` stays for older webmail builds:
// `disabled` only when every protocol is off.
let legacy_off = self.legacy_off_for_account(access_token).await?;
let legacy_protocols = if legacy_off.all() {
"disabled"
} else {
"enabled"
};
let legacy_allowed = legacy_off.allowed();
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none()
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
Capabilities::Inbuxa(InbuxaAccountCapabilities {
logo,
legacy_protocols,
legacy_allowed,
ai_explain,
}),
);
+20 -6
View File
@@ -376,7 +376,11 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
}
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
use inbuxa_features::{ai::limits, audit::log, security};
use inbuxa_features::{
ai::limits,
audit::log,
security::{self, protocol_policy::Switches},
};
let data = server.store();
match object {
"inbuxa:AuditSettings" => log::settings(data)
@@ -387,10 +391,17 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
.await
.ok()
.and_then(|limits| serde_json::to_value(limits).ok()),
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data)
.await
.ok()
.and_then(|policy| serde_json::to_value(policy).ok()),
// Normalized, so every switch reads before and after, even from a
// policy stored before the per-protocol switches
"inbuxa:ProtocolPolicy" => {
security::protocol_policy::get(data)
.await
.ok()
.and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
}
// LH-1: a hold as the API shows it, so a change reads before/after
"inbuxa:LegalHold" => match id {
MaybeInvalid::Value(id) => {
@@ -424,7 +435,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
security::tenant_protocol_policy::get(data, id.document_id())
.await
.ok()
.and_then(|policy| serde_json::to_value(policy).ok())
.and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
}
MaybeInvalid::Invalid(_) => None,
},
+105 -23
View File
@@ -26,7 +26,9 @@ use common::{
};
use inbuxa_features::security::{
listeners,
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
protocol_policy::{
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
},
};
use jmap_proto::{
error::set::SetError,
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
const ALL: &[P] = &[
P::Id,
P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::CloseSubmission,
P::SavedListeners,
P::ChangedAt,
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
Value::Object(out)
}
/// A switch as JMAP spells it.
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
match value {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
}
/// A switch from JMAP.
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
match value {
Some("enabled") => Ok(LegacyProtocols::Enabled),
Some("disabled") => Ok(LegacyProtocols::Disabled),
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
}
}
/// The JMAP name of a per-protocol switch property.
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value(
policy: &Policy,
would_close: &[SavedListener],
recent: &[RecentUse],
properties: &[P],
) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
P::LegacyProtocols => Value::Str(
match policy.legacy_protocols {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
.into(),
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
),
P::CloseSubmission => Value::Bool(policy.close_submission),
P::SavedListeners => Value::Array(
@@ -176,10 +208,11 @@ where
)
}
/// The listeners turning the switch on would close, whatever it is now.
/// The listeners turning every protocol off would close, whatever the switches
/// are now; each names its protocol, so the console shows one protocol's.
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
let mut hypothetical = policy.clone();
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
hypothetical.set_all(LegacyProtocols::Disabled);
hypothetical.apply_locks();
listeners::would_close(server.registry(), &hypothetical).await
}
@@ -238,12 +271,12 @@ fn apply(
value: &Value<'_, P, ProtocolPolicyValue>,
) -> Result<(), String> {
match property {
P::LegacyProtocols => {
policy.legacy_protocols = match value.as_str().as_deref() {
Some("enabled") => LegacyProtocols::Enabled,
Some("disabled") => LegacyProtocols::Disabled,
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
}
// The kill-all sets all three; a protocol named in the same /set is
// applied after it (see `set`), so it wins.
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
P::Imap | P::Pop3 | P::ManageSieve => {
let value = parse_switch(value.as_str().as_deref())?;
policy.set(switch_name(property).unwrap_or_default(), value);
}
P::CloseSubmission => {
policy.close_submission = value
@@ -262,7 +295,13 @@ fn apply(
/// Puts a property back to its default (a `null` in `/set`).
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
match property {
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
P::Imap | P::Pop3 | P::ManageSieve => {
policy.set(
switch_name(property).unwrap_or_default(),
LegacyProtocols::Enabled,
);
}
P::CloseSubmission => policy.close_submission = defaults.close_submission,
P::Id => return Err("is immutable".to_string()),
other if other.is_server_set() => return Err("is set by the server".to_string()),
@@ -312,10 +351,14 @@ pub async fn set(
}
let mut policy = server.protocol_policy().await?;
policy.normalize();
let defaults = Policy::default();
let mut error = None;
for (key, value) in value.into_expanded_object() {
// The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
let protocol = listeners::protocol_name(listener.protocol);
// A submission listener closes because of its port, not its protocol
// (LP-3), so the port is what would have to change.
let property = if protocol == "smtp" {
Property::Bind
let (property, what) = if protocol == "smtp" {
(Property::Bind, "Legacy mail protocols are".to_string())
} else {
Property::Protocol
(Property::Protocol, format!("{} is", display_name(protocol)))
};
Some((
property,
format!(
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
listener would reopen a port the switch keeps closed. Turn legacy protocols \
back on first."
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
a port the switch keeps closed. Turn it back on first."
),
))
}
/// A protocol's name as people read it.
fn display_name(protocol: &str) -> &str {
match protocol {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -461,6 +513,36 @@ mod tests {
}
}
#[test]
fn one_protocol_off_refuses_only_its_listeners() {
let mut policy = Policy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
let (property, why) = listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
)
.expect("refused");
assert_eq!(property, Property::Protocol);
assert!(why.starts_with("POP3 is off"), "{why}");
assert!(
listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Imap, "[::]:993")
)
.is_none()
);
}
#[test]
fn a_switch_reads_and_parses_as_jmap_spells_it() {
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
assert!(parse_switch(Some("off")).is_err());
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
assert_eq!(switch_name(&P::CloseSubmission), None);
}
#[test]
fn off_still_allows_what_the_switch_never_closes() {
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
@@ -12,16 +12,22 @@
//! with no ids answers with it, and any other id is `notFound`. At server
//! level, `/get` with no ids answers with every tenant's.
//!
//! Turning it off never needs the server's leave; turning it back on is
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
//! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
//! off never needs the server's leave; turning one back on is refused with
//! `forbidden` while the server has that protocol off (LP-9).
//! A tenant's switch closes no port (LP-13) -- sign-in and client
//! configuration read it (LP-10, LP-14a).
use crate::inbuxa::protocol_policy::recent_value;
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
use common::{
Server,
auth::AccessToken,
network::legacy::{RecentUse, switches_value},
};
use inbuxa_features::{
security::{
protocol_policy::LegacyProtocols,
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
},
tenancy::quota::all_tenants,
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
P::Id,
P::TenantId,
P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::ChangedAt,
P::ChangedBy,
P::RecentLegacyUse,
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
}
}
/// The JMAP name of a per-protocol switch property.
fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id | P::TenantId => {
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
}
P::LegacyProtocols => Value::Str(
match policy.legacy_protocols {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
.into(),
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
),
P::ChangedAt => policy
.changed_at
@@ -165,29 +184,41 @@ pub async fn set(
let data = &server.core.storage.data;
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
let mut policy = previous.clone();
policy.normalize();
let mut error = None;
for (key, value) in value.into_expanded_object() {
// What this request sets to `enabled`, for LP-9.
let mut turned_on: Vec<&'static str> = Vec::new();
// The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
// `null` puts a switch back to its default, on.
let parsed = match value {
Value::Null => Ok(LegacyProtocols::Enabled),
value => parse_switch(value.as_str().as_deref()),
};
let result = match &key {
Key::Property(P::LegacyProtocols) => match value {
Value::Null => {
policy.legacy_protocols = LegacyProtocols::Enabled;
Ok(())
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
policy.set_all(value);
if !value.is_disabled() {
turned_on.extend(SWITCHED.iter().copied());
} else {
turned_on.clear();
}
value => match value.as_str().as_deref() {
Some("enabled") => {
policy.legacy_protocols = LegacyProtocols::Enabled;
Ok(())
}),
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
parsed.map(|value| {
let name = switch_name(property).unwrap_or_default();
policy.set(name, value);
turned_on.retain(|p| *p != name);
if !value.is_disabled() {
turned_on.push(name);
}
Some("disabled") => {
policy.legacy_protocols = LegacyProtocols::Disabled;
Ok(())
}
_ => Err(r#"must be "enabled" or "disabled""#),
},
},
Key::Property(P::Id) => Err("is immutable"),
Key::Property(_) => Err("is set by the server"),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
})
}
Key::Property(P::Id) => Err("is immutable".to_string()),
Key::Property(_) => Err("is set by the server".to_string()),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
};
if let Err(why) = result {
error = Some(
@@ -203,15 +234,18 @@ pub async fn set(
continue;
}
// LP-9: server off means off for everyone.
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
// LP-9: server off means off for everyone, protocol by protocol.
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
response
.not_updated
.append(id, SetError::forbidden().with_description(why));
continue;
}
if policy.legacy_protocols != previous.legacy_protocols {
policy.normalize();
let mut before = previous;
before.normalize();
if policy.off() != before.off() {
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
@@ -221,11 +255,7 @@ pub async fn set(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "tenant",
Id = tenant_id,
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
Value = switches_value(&policy),
AccountId = policy.changed_by.clone(),
);
}