From 8e9cedbe9725f56a1b612590b02b88c9a201fa16 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 23:12:32 -0700 Subject: [PATCH] Give IMAP, POP3 and ManageSieve a switch each 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. --- crates/common/src/config/network.rs | 40 +-- .../src/network/autoconfig/autodiscover.rs | 8 +- .../network/autoconfig/legacy_autoconfig.rs | 6 +- crates/common/src/network/dns/records.rs | 21 +- crates/common/src/network/legacy.rs | 254 +++++++++++++----- .../features/src/security/protocol_policy.rs | 240 ++++++++++++++++- .../src/security/tenant_protocol_policy.rs | 159 +++++++++-- .../src/object/inbuxa_protocol_policy.rs | 13 +- .../object/inbuxa_tenant_protocol_policy.rs | 13 +- crates/jmap-proto/src/request/capability.rs | 5 + crates/jmap/src/api/session.rs | 9 +- crates/jmap/src/inbuxa/audit.rs | 26 +- crates/jmap/src/inbuxa/protocol_policy.rs | 128 +++++++-- .../jmap/src/inbuxa/tenant_protocol_policy.rs | 106 +++++--- tests/e2e/legacy_protocols.py | 101 +++++++ 15 files changed, 930 insertions(+), 199 deletions(-) diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index 39b9a19..04bb39a 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -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, pub mxs: Vec, pub services: VecMap, } @@ -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, }, }; diff --git a/crates/common/src/network/autoconfig/autodiscover.rs b/crates/common/src/network/autoconfig/autodiscover.rs index ab3e432..cefea55 100644 --- a/crates/common/src/network/autoconfig/autodiscover.rs +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -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\tsettings"); // 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 { diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs index c863064..35617be 100644 --- a/crates/common/src/network/autoconfig/legacy_autoconfig.rs +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -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{domain}" ); 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 { diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index f9eb2e7..8a77d40 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -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 { // 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!()) diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index a38ac03..fa9d68c 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -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>(names: impl Iterator) -> 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 { - 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 { + 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 { + 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 { - 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 { - 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 { + 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("a@b.test")), Some("b.test".to_string())); diff --git a/crates/features/src/security/protocol_policy.rs b/crates/features/src/security/protocol_policy.rs index 8b50e96..345829e 100644 --- a/crates/features/src/security/protocol_policy.rs +++ b/crates/features/src/security/protocol_policy.rs @@ -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, + /// POP3's switch. Unset reads as `legacy_protocols`. + #[serde(skip_serializing_if = "Option::is_none")] + pub pop3: Option, + /// ManageSieve's switch. Unset reads as `legacy_protocols`. + #[serde(skip_serializing_if = "Option::is_none")] + pub manage_sieve: Option, /// 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>; + fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option>; + 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> { + 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> { + 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() { diff --git a/crates/features/src/security/tenant_protocol_policy.rs b/crates/features/src/security/tenant_protocol_policy.rs index 81e72d9..67e4d60 100644 --- a/crates/features/src/security/tenant_protocol_policy.rs +++ b/crates/features/src/security/tenant_protocol_policy.rs @@ -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, + /// POP3's switch. Unset reads as `legacy_protocols`. + #[serde(skip_serializing_if = "Option::is_none")] + pub pop3: Option, + /// ManageSieve's switch. Unset reads as `legacy_protocols`. + #[serde(skip_serializing_if = "Option::is_none")] + pub manage_sieve: Option, /// When it last changed, in milliseconds since the epoch. pub changed_at: Option, /// The account that last changed it. pub changed_by: Option, } -/// 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 { + 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 { + 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); diff --git a/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs b/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs index 2430743..b1fe7f2 100644 --- a/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs +++ b/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs @@ -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, diff --git a/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs index a6e0123..469a675 100644 --- a/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs +++ b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs @@ -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, diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 66909b7..0344e15 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -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). diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index f74feb3..bd2cd1b 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -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, }), ); diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index 7e397a3..0926990 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -376,7 +376,11 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option) -> Option { - 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) -> 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) -> 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, }, diff --git a/crates/jmap/src/inbuxa/protocol_policy.rs b/crates/jmap/src/inbuxa/protocol_policy.rs index 6311b62..22be45e 100644 --- a/crates/jmap/src/inbuxa/protocol_policy.rs +++ b/crates/jmap/src/inbuxa/protocol_policy.rs @@ -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 { + 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> { 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, diff --git a/crates/jmap/src/inbuxa/tenant_protocol_policy.rs b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs index 8b9c95f..41aebc6 100644 --- a/crates/jmap/src/inbuxa/tenant_protocol_policy.rs +++ b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs @@ -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 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(), ); } diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 1994c0a..0c15ce7 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -34,6 +34,12 @@ account which way its switches point (test 13), and the impact panel's list names who signed in over what: every account at server scope, only the tenant's own at tenant scope, rewritten at most once an hour (LP-15). +Then one switch per protocol: POP3 alone off closes only POP3's port and +refuses only POP3 sign-in, sending and IMAP go on, and only POP3 stops being +advertised; one /set can close one protocol and reopen another. And a +tenant turning POP3 off for itself, and not able to turn IMAP back on while +the server has IMAP off. + Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. """ @@ -380,6 +386,37 @@ def tenant_checks(admin, admin_pw, account): "and its user signs in over IMAP again") check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)") + # One protocol at a time, for a tenant. + tone = lambda update: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", + {"accountId": tacct, "update": {t: update}}) + res = tone({"pop3": "disabled"}) + check(t in (res[1].get("updated") or {}), "a tenant admin turns POP3 alone off") + check(pop3_login(PORTS["pop3"], tu, user_pw) == + "-ERR [AUTH] Your organization allows only inbuxa webmail and JMAP apps. " + "This mail app can't sign in.", "the tenant's user is refused over POP3") + check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), + "and still signs in over IMAP") + check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0].startswith("235"), + "and still sends") + check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"), + "an account outside the tenant still signs in over POP3") + check(session_allowed(tu, user_pw) == ["imap", "manageSieve", "submission"], + "the tenant user's session leaves POP3 out") + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": {"imap": "disabled"}}}) + res = tone({"imap": "enabled"}) + refused = (res[1].get("notUpdated") or {}).get(t) or {} + check(refused.get("type") == "forbidden" + and (refused.get("description") or "").startswith("IMAP is off"), + "with IMAP off server-wide, the tenant can't turn IMAP on, and is told which (LP-9)") + res = tone({"pop3": "enabled"}) + check(t in (res[1].get("updated") or {}), "but it can turn its own POP3 back on") + check(session_allowed(tu, user_pw) == ["pop3", "manageSieve", "submission"], + "the session follows: IMAP off by the server, POP3 back") + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": {"imap": "enabled"}}}) + check(settle(PORTS["imap"], True), "IMAP back after the server's switch returns") + # A deleted tenant's switch goes with it, so a tenant that later gets the # same id doesn't start with legacy protocols off. sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get", @@ -406,6 +443,67 @@ def session_flag(user, password): return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols") +def session_allowed(user, password): + """legacyAllowed from the account's urn:inbuxa:jmap capability.""" + sess = session(user, password) + acct = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0] + return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyAllowed") + + +def per_protocol_checks(admin, admin_pw, account): + """One switch per protocol, server-wide.""" + pset = lambda update: one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": update}}) + pget = lambda: one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", + {"accountId": account, "ids": None})[1]["list"][0] + changes = len(events("security.legacy-protocols-changed")) + + res = pset({"pop3": "disabled"}) + check("singleton" in (res[1].get("updated") or {}), "POP3 alone can be turned off") + check(settle(PORTS["pop3"], False), "POP3 stopped accepting") + check(accepts(PORTS["imap"]), "IMAP still accepts with only POP3 off") + policy = pget() + check((policy["imap"], policy["pop3"], policy["manageSieve"], policy["legacyProtocols"]) + == ("enabled", "disabled", "enabled", "enabled"), + "the switches read back: POP3 off, the rest on, the kill-all not set") + check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), + "IMAP sign-in works with only POP3 off") + check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"), + "submission sign-in works: sending goes on while any protocol is allowed") + check(session_allowed(admin, admin_pw) == ["imap", "manageSieve", "submission"], + "the session lists what is still allowed") + check(session_flag(admin, admin_pw) == "enabled", + "and the old legacyProtocols flag still says enabled") + during = advertised(admin, admin_pw) + check(during["autoconfig"] == {"imap", "smtp"}, "autoconfig drops POP3 only") + check("pop3" not in during["pacc"] and {"imap", "smtp"} <= during["pacc"], + "PACC drops POP3 only") + check(during["srv"].get("_pop3s._tcp") == "." and during["srv"].get("_imaps._tcp") != ".", + "the zone marks POP3 not offered and still offers IMAP") + changed = events("security.legacy-protocols-changed")[changes:] + check(len(changed) == 1 and 'value = "pop3 disabled"' in changed[0] + and 'details = "closed"' in changed[0], + "turning POP3 off is one event naming it") + if len(changed) != 1: + print(" events:", changed) + + # One /set can close one protocol and bring another back. + pset({"pop3": "enabled", "imap": "disabled"}) + check(settle(PORTS["imap"], False), "IMAP closes in the same change") + check(settle(PORTS["pop3"], True), "that brings POP3 back") + check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"), + "POP3 sign-in works again") + changed = events("security.legacy-protocols-changed")[changes + 1:] + check(len(changed) == 1 and 'details = "closed and reopened"' in changed[0], + "and it is one event, closed and reopened") + + pset({"imap": "enabled"}) + check(settle(PORTS["imap"], True), "IMAP back on") + policy = pget() + check(not policy["savedListeners"] and policy["legacyProtocols"] == "enabled", + "nothing left saved once every protocol is on") + + def events_matching(name, *parts): return any(all(p in line for p in parts) for line in events(name)) @@ -636,6 +734,9 @@ def main(): check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], "autoconfig and the suggested zone offer them again once back on") + # One switch per protocol. + per_protocol_checks(admin, admin_pw, account) + # A tenant's own switch (LP-9 to LP-14a). tenant_checks(admin, admin_pw, account)