From 4b585905d71dbba90e6a319892001fb79d64173b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 10:41:20 -0700 Subject: [PATCH 1/6] Nothing advertises the legacy protocols while they are off (LP-7) While the switch is off, the answers that tell a mail app where to connect stop offering what the switch closed, so a new phone or desktop app is not sent to a port that is shut or a sign-in that will be refused: - Thunderbird-style autoconfig (/mail/config-v1.1.xml and its other paths) and Outlook autodiscover leave out IMAP, POP3 and SMTP submission. - PACC (/.well-known/user-agent-configuration.json) offers JMAP, CalDAV, CardDAV and WebDAV, and no IMAP, POP3, SMTP or ManageSieve. The document is rendered once per configuration load, so the JMAP-only version is rendered beside it and chosen per request; the _ua-auto-config digest in the suggested zone follows, since it hashes the same document. - The suggested zone publishes _imap, _imaps, _pop3, _pop3s, _submission and _submissions with target "." -- "not offered", RFC 6186 section 3.4 -- the spec's decision, rather than dropping them: a client that looks is told, and an automatically managed zone replaces the old records instead of leaving them behind. - It also drops the TLSA records for ports 993 and 995. A TLS pin for a port the switch has closed advertises a service that is not there. Submission's 465 keeps its record: the SMTP lock keeps that port open. The switch is read per answer, as sign-in reads it, so every node agrees the moment it turns. Inbound mail, MX records and the JMAP, CalDAV and CardDAV answers are untouched. tests/e2e/legacy_protocols.py checks all four on a running server: with the switch on they offer IMAP, POP3 and SMTP (the control); while it is off they offer none of them and every legacy SRV name has target "."; and once it is back on, autoconfig and the zone read as they did before. All checks pass. --- crates/common/src/config/network.rs | 31 +++++++-- .../src/network/autoconfig/autodiscover.rs | 9 ++- .../network/autoconfig/legacy_autoconfig.rs | 10 ++- crates/common/src/network/dns/records.rs | 53 ++++++++++++--- crates/common/src/network/legacy.rs | 46 +++++++++++++ tests/e2e/legacy_protocols.py | 64 ++++++++++++++++++- 6 files changed, 195 insertions(+), 18 deletions(-) diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index ad647cd..39b9a19 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -47,6 +47,9 @@ 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, pub mxs: Vec, pub services: VecMap, } @@ -320,11 +323,26 @@ impl Network { } } - let (prefix, suffix) = serde_json::to_string(&pacc) - .unwrap_or_default() - .rsplit_once(SPLIT_HERE) - .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) - .unwrap(); + let split = |pacc: &Configuration| { + serde_json::to_string(pacc) + .unwrap_or_default() + .rsplit_once(SPLIT_HERE) + .map(|(prefix, suffix)| Pacc { + prefix: prefix.to_string(), + suffix: suffix.to_string(), + }) + .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); let mut network = Network { node_id: bp.node_id() as u64, server_name: default_hostname.to_string(), @@ -339,7 +357,8 @@ impl Network { info: NetworkInfo { mxs: system.mail_exchangers.into_iter().collect(), services: system.services, - pacc: Pacc { prefix, suffix }, + pacc, + pacc_jmap_only, }, }; diff --git a/crates/common/src/network/autoconfig/autodiscover.rs b/crates/common/src/network/autoconfig/autodiscover.rs index c548a48..5818ba8 100644 --- a/crates/common/src/network/autoconfig/autodiscover.rs +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{Server, manager::application::Resource}; +use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use quick_xml::Reader; use quick_xml::XmlVersion; use quick_xml::events::Event; @@ -55,7 +57,12 @@ impl Server { let _ = writeln!(&mut config, "\t\t"); let _ = writeln!(&mut config, "\t\t\temail"); let _ = writeln!(&mut config, "\t\t\tsettings"); + // inbuxa: legacy-protocols LP-7 + let legacy_off = self.legacy_protocols_off().await?; for (protocol, service) in &self.core.network.info.services { + if legacy_off && is_legacy_service(protocol) { + continue; + } let (protocol, ports) = match protocol { ServiceProtocol::Imap => ("IMAP", [143, 993]), ServiceProtocol::Pop3 => ("POP3", [110, 995]), diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs index 2f2e2cb..3bb9c0f 100644 --- a/crates/common/src/network/autoconfig/legacy_autoconfig.rs +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{Server, manager::application::Resource}; +use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use registry::schema::enums::ServiceProtocol; use std::fmt::Write; use utils::url_params::UrlParams; @@ -28,6 +30,9 @@ impl Server { ("%EMAILADDRESS%", default_host.as_str()) }; + // inbuxa: legacy-protocols LP-7 + let legacy_off = self.legacy_protocols_off().await?; + // Build XML response let mut config = String::with_capacity(1024); config.push_str("\n"); @@ -40,6 +45,9 @@ impl Server { "\t\t{domain}" ); for (protocol, service) in &self.core.network.info.services { + if legacy_off && is_legacy_service(protocol) { + continue; + } let (protocol, tag, ports) = match protocol { ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]), ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]), diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index 221ae15..f371522 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -2,9 +2,15 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record}; +use crate::{ + Server, + config::network::Pacc, + network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service}, +}; use ahash::{AHashMap, AHashSet}; use base64::{Engine, engine::general_purpose}; use dns_update::{ @@ -33,6 +39,8 @@ impl Server { let mut records = Vec::new(); let network = &self.core.network; let default_host = network.server_name.as_str(); + // inbuxa: legacy-protocols LP-7 + let legacy_off = self.legacy_protocols_off().await?; let domain_name = domain.name.as_str(); let domain_name_suffix = format!(".{domain_name}"); @@ -193,6 +201,25 @@ impl Server { ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)], }; + // inbuxa: legacy-protocols LP-7. While they are off, every + // 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) { + for (service_name, _) in services { + records.push(NamedDnsRecord { + name: format!("_{service_name}._tcp.{domain_name}."), + record: DnsRecord::SRV(SRVRecord { + target: ".".to_string(), + priority: 0, + weight: 0, + port: 0, + }), + }); + } + continue; + } + for (is_tls, (service_name, port)) in services.into_iter().enumerate() { if is_tls == 1 || service.cleartext { records.push(NamedDnsRecord { @@ -277,6 +304,14 @@ impl Server { for (protocol, service) in &network.info.services { let hostname = service.hostname.as_deref().unwrap_or(default_host); if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { + // 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) + { + continue; + } let port = match protocol { ServiceProtocol::Imap => 993, ServiceProtocol::Pop3 => 995, @@ -382,6 +417,12 @@ impl Server { } pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result { + // inbuxa: legacy-protocols LP-7 + let pacc = if self.legacy_protocols_off().await? { + &self.core.network.info.pacc_jmap_only + } else { + &self.core.network.info.pacc + }; self.get_directory_for_domain(domain_name) .await .caused_by(trc::location!()) @@ -390,15 +431,9 @@ impl Server { .and_then(|directory| { directory .oidc_discovery_document() - .map(|doc| self.core.network.info.pacc.build(&doc.url)) - }) - .unwrap_or_else(|| { - self.core - .network - .info - .pacc - .build(&self.core.network.http.url_https) + .map(|doc| pacc.build(&doc.url)) }) + .unwrap_or_else(|| pacc.build(&self.core.network.http.url_https)) }) } } diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index 441c1d8..fbf49ed 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -21,6 +21,10 @@ //! a legacy protocol is refused before any password is looked at, so a //! listener that exists by mistake still lets nobody in. //! +//! And nothing advertises what is closed (LP-7): client configuration and +//! the suggested DNS records leave the legacy services out, or mark them as +//! not offered, while the switch is off. +//! //! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! (LP-20). The server stops answering; what still routes the port is the //! operator's to reconcile. @@ -31,6 +35,7 @@ use inbuxa_features::security::{ listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, }; +use registry::schema::enums::ServiceProtocol; use registry::types::{error::Error, id::ObjectId}; use store::registry::bootstrap::Bootstrap; @@ -302,6 +307,27 @@ impl Server { } } +/// The services mail apps sign in to, which the switch turns off: nothing may +/// offer them while it is (LP-7). SMTP here is submission -- mail apps +/// sending -- since inbound mail is never a configured service. +pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool { + matches!( + protocol, + ServiceProtocol::Imap + | ServiceProtocol::Pop3 + | ServiceProtocol::Smtp + | ServiceProtocol::Managesieve + ) +} + +impl Server { + /// Whether the server-wide switch is off, for the answers that must stop + /// offering legacy services (LP-7). Read per answer, as sign-in reads it. + pub async fn legacy_protocols_off(&self) -> trc::Result { + Ok(self.protocol_policy().await?.legacy_protocols.is_disabled()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -349,6 +375,26 @@ mod tests { assert_eq!(err.value_as_str(trc::Key::AccountName), None); } + #[test] + fn only_the_services_mail_apps_sign_in_to_are_legacy() { + for protocol in [ + ServiceProtocol::Imap, + ServiceProtocol::Pop3, + ServiceProtocol::Smtp, + ServiceProtocol::Managesieve, + ] { + assert!(is_legacy_service(&protocol), "{protocol:?}"); + } + for protocol in [ + ServiceProtocol::Jmap, + ServiceProtocol::Caldav, + ServiceProtocol::Carddav, + ServiceProtocol::Webdav, + ] { + assert!(!is_legacy_service(&protocol), "{protocol:?}"); + } + } + #[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/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 7831291..e310bf0 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -17,7 +17,9 @@ submission -- locked open -- is refused with the spec's words, with the right password and with a wrong one, and refusals never add up to a disconnect (LP-11). And that a normal IMAP sign-in works with the switch on, before and after. And that while it is off, no listener the switch would close can be -created, or made by an update (LP-4, test 4). +created, or made by an update (LP-4, test 4), and nothing advertises what is +closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission, +and the suggested zone marks their SRV names not offered (LP-7, test 5). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -189,6 +191,40 @@ def smtp_auths(port, user, passwords): return replies +def advertised(admin, admin_pw): + """What each client-configuration answer and the suggested zone offer.""" + with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress=a@legacy.test", + timeout=30) as resp: + autoconfig = resp.read().decode() + body = ('' + 'a@legacy.testhttp://' + 'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' + '').encode() + req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST") + req.add_header("Content-Type", "text/xml") + with urllib.request.urlopen(req, timeout=30) as resp: + autodiscover = resp.read().decode() + with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json", + timeout=30) as resp: + pacc = json.load(resp).get("protocols", {}) + got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]}) + zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", []) + if d.get("name") == "legacy.test"), "") + srv = {} + for line in zone.splitlines(): + fields = line.split() + if "SRV" in fields and fields[0].startswith("_"): + srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1] + return { + "autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig}, + "autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"{t}" in autodiscover}, + "pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc}, + "jmap": "jmap" in pacc, + "srv": srv, + } + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -243,6 +279,15 @@ def main(): check(accepts(PORTS["submissions"]), "submission accepts before the switch") check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch") + # What is advertised with the switch on -- the control for LP-7. + before = advertised(admin, admin_pw) + print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v + for k, v in before.items() if k != "srv"}) + check(before["autoconfig"] and before["autodiscover"], + "autoconfig and autodiscover offer mail apps a server with the switch on") + check(before["srv"].get("_imaps._tcp", ".") != ".", + "the suggested zone offers IMAP with the switch on") + # A normal sign-in works with the switch on -- the control for LP-6. check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), "IMAP sign-in works with the switch on") @@ -295,6 +340,19 @@ def main(): if not all(r == SMTP_REFUSAL for r in replies): print(" replies:", replies) + # Nothing advertises what is closed (LP-7, test 5). + during = advertised(admin, admin_pw) + check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)") + check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)") + check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)") + names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp", + "_submission._tcp", "_submissions._tcp") + offered = {n: t for n, t in during["srv"].items() if n in names and t != "."} + check(not offered and "_imaps._tcp" in during["srv"], + "the suggested zone marks the legacy SRV names not offered, target . (LP-7)") + if offered or "_imaps._tcp" not in during["srv"]: + print(" srv:", during["srv"]) + # No listener the switch would close can be added while it is off (LP-4, # test 4), and the refusal names the policy. res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": { @@ -341,6 +399,10 @@ def main(): check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") + after = advertised(admin, admin_pw) + check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], + "autoconfig and the suggested zone offer them again once back on") + # And sign-in works again, with no restart. check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), "IMAP sign-in works again once the switch is back on") From 64cddc9246310dfa06195640df5932f202e7e0a8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 10:53:01 -0700 Subject: [PATCH 2/6] The switch reports every change as an event (LP-8) Turning legacy mail protocols off or back on raises security.legacy-protocols-changed (id 643, info level, also in the packaged schema), with the scope (policy = server), the new value, who made the change (accountId), whether listeners closed or reopened (details), which ones (listenerId), and -- only when a listener could not be put back -- which and why (reason). It is raised in Server::set_protocol_policy rather than by the JMAP method, so whatever turns the switch is reported. A /set that changes nothing -- the switch already where it was asked to be, nothing to close or reopen -- is not a change and raises nothing. The event is never an error, but jmap's exhaustive map from security events to HTTP errors has to name it; it joins the other two that can't occur there. rustfmt now also wraps LP-6's two over-long lines in enums_impl.rs, which it flagged along with this change's. tests/e2e/legacy_protocols.py now gives the server a stdout tracer and reads events from the container's log: turning the switch off is exactly one event naming the scope, value, author and listeners closed; setting it off again raises none; turning it on is one event naming the listeners reopened. It also proves LP-6's side: seven refused submission sign-ins are seven auth.legacy-protocol-refused events, and there is no auth.failed or auth.too-many-attempts among them. All checks pass. --- crates/common/src/network/legacy.rs | 37 +++++++++++++++++++++++++ crates/jmap/src/api/mod.rs | 7 ++++- crates/trc/src/event/enums.rs | 7 +++-- crates/trc/src/event/enums_impl.rs | 30 ++++++++++++++++++-- resources/schema/schema.json.gz | Bin 150399 -> 150520 bytes resources/schema/schema.json.sha256 | 2 +- tests/e2e/legacy_protocols.py | 41 ++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 6 deletions(-) diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index fbf49ed..8952de1 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -107,6 +107,37 @@ impl Server { 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 + // 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") + }; + trc::event!( + Security(trc::SecurityEvent::LegacyProtocolsChanged), + Policy = "server", + Value = if policy.legacy_protocols.is_disabled() { + "disabled" + } else { + "enabled" + }, + AccountId = policy.changed_by.clone(), + Details = direction, + ListenerId = listener_names(moved.iter().map(|l| l.id.clone())), + // Only when a listener could not be put back (LP-5). + Reason = (!change.failed.is_empty()).then(|| listener_names( + change + .failed + .iter() + .map(|(l, why)| format!("{}: {why}", l.id)) + )), + ); + } + Ok(change) } @@ -220,6 +251,12 @@ impl Server { } } +/// 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 { + trc::Value::Array(names.map(Into::into).collect()) +} + /// A protocol a mail app signs in over, which the switch refuses (LP-6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LegacyProtocol { diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 62377d0..2e61190 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::blob::UploadResponse; @@ -186,7 +188,10 @@ impl ToRequestError for trc::Error { trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { RequestError::forbidden() } - trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => { + // inbuxa: legacy-protocols LP-8 is an event, never an error + trc::SecurityEvent::IpBlockExpired + | trc::SecurityEvent::IpAllowExpired + | trc::SecurityEvent::LegacyProtocolsChanged => { RequestError::internal_server_error() } }, diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index a1ac625..1725ed7 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -9,8 +9,9 @@ // This file is auto-generated. Do not edit directly. // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is -// auth.legacy-protocol-refused (legacy-protocols LP-6) -pub const TOTAL_EVENT_COUNT: usize = 643; +// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is +// security.legacy-protocols-changed (LP-8) +pub const TOTAL_EVENT_COUNT: usize = 644; pub const TOTAL_METRIC_COUNT: usize = 369; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -655,6 +656,8 @@ pub enum SecurityEvent { IpAllowExpired = 594, IpUnauthorized = 279, Unauthorized = 552, + // inbuxa: legacy-protocols LP-8 + LegacyProtocolsChanged = 643, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index 06f9bbe..ddb21c1 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -446,6 +446,8 @@ impl EventType { b"security.ip-allow-expired" => EventType::Security(SecurityEvent::IpAllowExpired), b"security.ip-unauthorized" => EventType::Security(SecurityEvent::IpUnauthorized), b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized), + // inbuxa: legacy-protocols LP-8 + b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged), b"server.startup" => EventType::Server(ServerEvent::Startup), b"server.shutdown" => EventType::Server(ServerEvent::Shutdown), b"server.startup-error" => EventType::Server(ServerEvent::StartupError), @@ -1211,6 +1213,10 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => "security.ip-allow-expired", EventType::Security(SecurityEvent::IpUnauthorized) => "security.ip-unauthorized", EventType::Security(SecurityEvent::Unauthorized) => "security.unauthorized", + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { + "security.legacy-protocols-changed" + } EventType::Server(ServerEvent::Startup) => "server.startup", EventType::Server(ServerEvent::Shutdown) => "server.shutdown", EventType::Server(ServerEvent::StartupError) => "server.startup-error", @@ -1883,6 +1889,8 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => 594, EventType::Security(SecurityEvent::IpUnauthorized) => 279, EventType::Security(SecurityEvent::Unauthorized) => 552, + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643, EventType::Server(ServerEvent::Startup) => 393, EventType::Server(ServerEvent::Shutdown) => 392, EventType::Server(ServerEvent::StartupError) => 394, @@ -2571,6 +2579,8 @@ impl EventType { 594 => Some(EventType::Security(SecurityEvent::IpAllowExpired)), 279 => Some(EventType::Security(SecurityEvent::IpUnauthorized)), 552 => Some(EventType::Security(SecurityEvent::Unauthorized)), + // inbuxa: legacy-protocols LP-8 + 643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)), 393 => Some(EventType::Server(ServerEvent::Startup)), 392 => Some(EventType::Server(ServerEvent::Shutdown)), 394 => Some(EventType::Server(ServerEvent::StartupError)), @@ -2990,6 +3000,8 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => Level::Info, EventType::Security(SecurityEvent::IpUnauthorized) => Level::Info, EventType::Security(SecurityEvent::Unauthorized) => Level::Info, + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info, EventType::Server(ServerEvent::Startup) => Level::Info, EventType::Server(ServerEvent::Shutdown) => Level::Info, EventType::Server(ServerEvent::Licensing) => Level::Info, @@ -3198,7 +3210,9 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", // inbuxa: legacy-protocols LP-6 - EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused", + EventType::Auth(AuthEvent::LegacyProtocolRefused) => { + "Legacy mail protocol sign-in refused" + } EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::Warning) => "Authentication warning", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", @@ -3711,6 +3725,10 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => "IP allow expired", EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address", EventType::Security(SecurityEvent::Unauthorized) => "Unauthorized access", + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { + "Legacy mail protocols switch changed" + } EventType::Server(ServerEvent::Startup) => "Starting INBUXA Server", EventType::Server(ServerEvent::Shutdown) => "Shutting down INBUXA Server", EventType::Server(ServerEvent::StartupError) => "Server startup error", @@ -3964,7 +3982,9 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", // inbuxa: legacy-protocols LP-6 - EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps", + EventType::Auth(AuthEvent::LegacyProtocolRefused) => { + "This server allows only INBUXA webmail and JMAP apps" + } EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error", @@ -4102,6 +4122,10 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => "Insufficient permissions", EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address", EventType::Security(SecurityEvent::Unauthorized) => "Insufficient permissions", + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { + "Legacy mail protocols switch changed" + } EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error", EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error", EventType::Smtp(SmtpEvent::Error) => "SMTP error", @@ -4663,6 +4687,8 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired), EventType::Security(SecurityEvent::IpUnauthorized), EventType::Security(SecurityEvent::Unauthorized), + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged), EventType::Server(ServerEvent::Startup), EventType::Server(ServerEvent::Shutdown), EventType::Server(ServerEvent::StartupError), diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index fa4afe43e98f7bf225a358cbe2587791f4d97fb1..54ac1e10b0e6b3a179f51911cac04bdf439ef990 100644 GIT binary patch delta 47250 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 diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index f7789de..7d5e6f8 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho \ No newline at end of file +7DAGhNQhe_TWHNme6m7dbMaqwfgHWxF31cwHFvqaMHU \ No newline at end of file diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index e310bf0..f74e614 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -20,6 +20,8 @@ after. And that while it is off, no listener the switch would close can be created, or made by an update (LP-4, test 4), and nothing advertises what is closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission, and the suggested zone marks their SRV names not offered (LP-7, test 5). +Every change of the switch, and every refused sign-in, is an event in the +server's log (LP-8, test 14; LP-6). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -225,6 +227,13 @@ def advertised(admin, admin_pw): } +def events(name): + """The server's log lines for one event, from its stdout tracer. The log + is the container's, so it starts afresh at every restart.""" + out = docker("logs", NAME, check_rc=False) + return [l for l in (out.stdout + out.stderr).splitlines() if f"({name})" in l] + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -268,6 +277,15 @@ def main(): stop() start() + # A tracer to stdout, so the events can be read back from the container's + # log. It takes effect from the next start. + res = one(admin, admin_pw, "x:Tracer/set", {"create": {"t": { + "@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}}) + if not (res[1].get("created") or {}).get("t"): + sys.exit("tracer create failed: " + json.dumps(res)) + stop() + start() + sess = session(admin, admin_pw) account = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0] policy_get = {"accountId": account, "ids": None} @@ -380,6 +398,25 @@ def main(): "turning it into an IMAP listener is refused (LP-4)") one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [extra]}) + # The change was reported (LP-8, test 14), with who made it and what closed. + changed = events("security.legacy-protocols-changed") + check(len(changed) == 1 and 'value = "disabled"' in changed[0] + and 'policy = "server"' in changed[0] and 'details = "closed"' in changed[0] + and '"imaps"' in changed[0] and "accountId = " in changed[0], + "turning it off is one event: scope, new value, who, listeners closed (LP-8)") + if len(changed) != 1: + print(" events:", changed) + # Asking for what already holds is not a change. + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", policy_set({"legacyProtocols": "disabled"})) + check(len(events("security.legacy-protocols-changed")) == 1, + "setting it off again when it is off raises no event (LP-8)") + # Every refused sign-in is an event too, and none is a failed sign-in. + refused = events("auth.legacy-protocol-refused") + check(len(refused) == 7 and all('source = "submission"' in l for l in refused), + "each refused sign-in is an auth.legacy-protocol-refused event (LP-6)") + check(not events("auth.failed") and not events("auth.too-many-attempts"), + "and none is logged as a failed sign-in (LP-11)") + # A restart must not reopen them: the objects are gone, not just the sockets. stop() start() @@ -398,6 +435,10 @@ def main(): policy = got[1]["list"][0] check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") + changed = events("security.legacy-protocols-changed") + check(len(changed) == 1 and 'value = "enabled"' in changed[0] + and 'details = "reopened"' in changed[0] and '"imaps"' in changed[0], + "turning it back on is one event, naming the listeners reopened (LP-8)") after = advertised(admin, admin_pw) check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], From b65afb66f9cbecc1cd1459caababc12e8913bf22 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 11:18:42 -0700 Subject: [PATCH 3/6] A tenant can turn legacy protocols off for itself (LP-9 to LP-14a) The tenant switch. A tenant's administrator turns legacy mail protocols off for its own tenant, and from then on sign-in over IMAP, POP3, ManageSieve and SMTP AUTH is refused for every address on the tenant's domains, while every other domain on the server carries on. No port closes, since other tenants share them (LP-13): it is one stored fact per tenant, read at sign-in and when client configuration is answered. inbuxa:TenantProtocolPolicy/get and /set, one per tenant, id the tenant's: - Inside a tenant, a principal reaches only its own tenant's switch (MT-1): /get with no ids answers with it, another tenant's is notFound and can't be changed. At server level /get with no ids lists every tenant's. - Turning it off is always allowed. Turning it back on is refused with forbidden, naming inbuxa:ProtocolPolicy, while the server has legacy protocols off (LP-9). - A change raises security.legacy-protocols-changed with policy = tenant, the tenant's id, the new value and who made it (LP-14). - It takes sysDomainGet and sysDomainUpdate, not the two new permissions the spec names. The switch governs sign-in on the tenant's domains, so whoever manages those domains may turn it -- and the default Tenant Administrator role already holds both, where new permissions would reach no role already stored on a server (MT-12's note), leaving today's tenant administrators without the switch until someone edited their role by hand. The same trade inbuxa:AiLimits and inbuxa:ProtocolPolicy made. /query is not built yet; /get with no ids covers listing. Sign-in (LP-10 to LP-12). Before the credentials are looked at, the name given is resolved to its domain and the domain to its tenant, so a real account and a made-up address on the domain get the same refusal, with a right password or a wrong one, counted as no failed sign-in (LP-11). The words are the spec's: "Your organization allows only INBUXA webmail and JMAP apps...", in each protocol's form. A bearer token needn't name an account, so after authentication the account's own tenant is checked too; a token that named nobody can't slip past. The refusal carries policy = tenant and the domain, not the tenant's id: IMAP answers a command's tag from the Id key, so an error holding one was sent under the wrong tag and the mail app hung waiting for its reply. The first live run found that; a unit test now holds the refusal to it. Client configuration (LP-14a). Autoconfig, autodiscover, PACC and the suggested DNS records now ask whether legacy services are off for the domain being answered for -- the server's switch, or the domain's tenant's -- so a tenant's domains stop offering IMAP, POP3 and submission while others still do. tests/e2e/legacy_protocols.py builds a tenant with its own domain, a user and a tenant administrator, and a second tenant, and proves on a running server: the admin sees and changes only its own tenant's switch (test 10); turning it off is an event (test 14); the tenant's user is refused over IMAP with the right password and a wrong one, a made-up address on the domain the same (tests 6, 7); POP3 and submission refuse in their own forms and JMAP still works (test 8); an account on another domain signs in normally (test 6); autoconfig drops IMAP for the tenant's domain only; with the server off, the tenant can't turn it back on (test 9); and once back on, the user signs in again. All 62 checks pass. --- .../src/network/autoconfig/autodiscover.rs | 7 +- .../network/autoconfig/legacy_autoconfig.rs | 4 +- crates/common/src/network/dns/records.rs | 8 +- crates/common/src/network/legacy.rs | 178 +++++++++++--- crates/features/src/security/mod.rs | 1 + .../src/security/tenant_protocol_policy.rs | 157 ++++++++++++ crates/imap/src/op/authenticate.rs | 6 + .../object/inbuxa_tenant_protocol_policy.rs | 180 ++++++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 6 + crates/jmap-proto/src/request/method.rs | 11 + crates/jmap-proto/src/request/mod.rs | 6 + crates/jmap-proto/src/request/parser.rs | 18 ++ crates/jmap-proto/src/response/mod.rs | 26 ++ crates/jmap/src/api/auth.rs | 15 +- crates/jmap/src/api/request.rs | 17 ++ crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/mod.rs | 1 + .../jmap/src/inbuxa/tenant_protocol_policy.rs | 224 ++++++++++++++++++ crates/managesieve/src/op/authenticate.rs | 5 + crates/pop3/src/op/authenticate.rs | 5 + crates/smtp/src/inbound/auth.rs | 42 +++- tests/e2e/legacy_protocols.py | 132 +++++++++++ 24 files changed, 1001 insertions(+), 53 deletions(-) create mode 100644 crates/features/src/security/tenant_protocol_policy.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs create mode 100644 crates/jmap/src/inbuxa/tenant_protocol_policy.rs diff --git a/crates/common/src/network/autoconfig/autodiscover.rs b/crates/common/src/network/autoconfig/autodiscover.rs index 5818ba8..ab3e432 100644 --- a/crates/common/src/network/autoconfig/autodiscover.rs +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -57,8 +57,11 @@ impl Server { let _ = writeln!(&mut config, "\t\t"); let _ = writeln!(&mut config, "\t\t\temail"); let _ = writeln!(&mut config, "\t\t\tsettings"); - // inbuxa: legacy-protocols LP-7 - let legacy_off = self.legacy_protocols_off().await?; + // 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?, + }; for (protocol, service) in &self.core.network.info.services { if legacy_off && is_legacy_service(protocol) { continue; diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs index 3bb9c0f..c863064 100644 --- a/crates/common/src/network/autoconfig/legacy_autoconfig.rs +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -30,8 +30,8 @@ impl Server { ("%EMAILADDRESS%", default_host.as_str()) }; - // inbuxa: legacy-protocols LP-7 - let legacy_off = self.legacy_protocols_off().await?; + // inbuxa: legacy-protocols LP-7, LP-14a + let legacy_off = self.legacy_protocols_off_for(domain).await?; // Build XML response let mut config = String::with_capacity(1024); diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index f371522..f9eb2e7 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -39,9 +39,9 @@ impl Server { let mut records = Vec::new(); let network = &self.core.network; let default_host = network.server_name.as_str(); - // inbuxa: legacy-protocols LP-7 - let legacy_off = self.legacy_protocols_off().await?; 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 domain_name_suffix = format!(".{domain_name}"); for record_type in record_types { @@ -417,8 +417,8 @@ impl Server { } pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result { - // inbuxa: legacy-protocols LP-7 - let pacc = if self.legacy_protocols_off().await? { + // 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 diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index 8952de1..d3e17b8 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -23,17 +23,19 @@ //! //! And nothing advertises what is closed (LP-7): client configuration and //! the suggested DNS records leave the legacy services out, or mark them as -//! not offered, while the switch is off. +//! not offered, while the switch is off -- the server's, or for a tenant's +//! domains, the tenant's (LP-14a). //! //! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! (LP-20). The server stops answering; what still routes the port is the //! operator's to reconcile. -use crate::{Server, config::server::Listeners, network::TcpAcceptor}; +use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor}; use directory::Credentials; use inbuxa_features::security::{ listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, + tenant_protocol_policy, }; use registry::schema::enums::ServiceProtocol; use registry::types::{error::Error, id::ObjectId}; @@ -278,39 +280,71 @@ impl LegacyProtocol { } } - /// What the mail app is told, at server scope (LP-12, LP-6). Each - /// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting — - /// is added by its session; POP3 carries `[AUTH]` in the text, since its - /// errors have no separate code, and SMTP is the whole reply line. - pub fn refusal(&self) -> &'static str { - match self { - LegacyProtocol::Imap => { + /// What the mail app is told (LP-12). Each protocol's own framing -- + /// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session; + /// POP3 carries `[AUTH]` in the text, since its errors have no separate + /// code, and SMTP is the whole reply line. At server scope "Your + /// organization" reads "This server" (LP-6). + pub fn refusal(&self, scope: RefusalScope) -> &'static str { + match (scope, self) { + (RefusalScope::Server, LegacyProtocol::Imap) => { "This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." } - LegacyProtocol::Pop3 => { + (RefusalScope::Server, LegacyProtocol::Pop3) => { "[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." } - LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.", - LegacyProtocol::Submission => { + (RefusalScope::Server, LegacyProtocol::ManageSieve) => { + "This server allows only INBUXA webmail and JMAP apps." + } + (RefusalScope::Server, LegacyProtocol::Submission) => { "535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" } + (RefusalScope::Tenant(_), LegacyProtocol::Imap) => { + "Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + } + (RefusalScope::Tenant(_), LegacyProtocol::Pop3) => { + "[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + } + (RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => { + "Your organization allows only INBUXA webmail and JMAP apps." + } + (RefusalScope::Tenant(_), LegacyProtocol::Submission) => { + "535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" + } } } /// The refusal as an error: `auth.legacy-protocol-refused`, not /// `auth.failed`, so it never counts against the account or feeds the - /// auto-ban (LP-11). It names the protocol and the domain, never the - /// account; the session it is raised in adds the remote IP. - pub fn refused(&self, credentials: &Credentials) -> trc::Error { + /// auto-ban (LP-11). It names the protocol, the scope and the domain, + /// never the account; the session adds the remote IP. + /// + /// Not the tenant's id: `Id` is what IMAP answers a command's tag from, + /// so an error carrying one is sent under the wrong tag and the mail app + /// waits for a reply that never comes. The domain names the tenant. + pub fn refused(&self, scope: RefusalScope, domain: Option) -> trc::Error { trc::AuthEvent::LegacyProtocolRefused .into_err() - .details(self.refusal()) + .details(self.refusal(scope)) .ctx(trc::Key::Source, self.as_str()) - .ctx(trc::Key::Policy, "server") - .ctx_opt(trc::Key::Domain, domain_of(credentials)) + .ctx( + trc::Key::Policy, + match scope { + RefusalScope::Server => "server", + RefusalScope::Tenant(_) => "tenant", + }, + ) + .ctx_opt(trc::Key::Domain, domain) } } +/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefusalScope { + Server, + Tenant(u32), +} + /// The domain a sign-in is for, from the name it gives, if it gives one. fn domain_of(credentials: &Credentials) -> Option { let username = match credentials { @@ -325,22 +359,58 @@ fn domain_of(credentials: &Credentials) -> Option { impl Server { /// Refuses a sign-in over a legacy protocol while the server-wide switch - /// is off (LP-6). Called before the credentials are checked, so the - /// answer is the same for a right password, a wrong one and an account - /// that doesn't exist (LP-11). + /// is off (LP-6), or while the switch of the tenant that owns the named + /// domain is (LP-10). Called before the credentials are checked, so the + /// answer is the same for a right password, a wrong one and an address + /// that doesn't exist (LP-11): a tenant's domain answers for every address + /// on it. /// /// Read from the store on each sign-in rather than cached, so every node - /// of a cluster answers the same the moment the switch turns. + /// of a cluster answers the same the moment a switch turns. pub async fn refuse_legacy_sign_in( &self, protocol: LegacyProtocol, credentials: &Credentials, ) -> trc::Result<()> { + let domain = domain_of(credentials); if self.protocol_policy().await?.legacy_protocols.is_disabled() { - Err(protocol.refused(credentials)) - } else { - Ok(()) + 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()))); + } + Ok(()) + } + + /// The same, once the account is known (LP-10). A bearer token needn't + /// name an account, so a sign-in with one can't be judged by its domain + /// beforehand; this judges it by the tenant the token turned out to + /// belong to. For a password sign-in it has already been decided. + pub async fn refuse_legacy_session( + &self, + 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)); + } + Ok(()) + } + + /// 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(), + ) } } @@ -358,10 +428,21 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool { } impl Server { - /// Whether the server-wide switch is off, for the answers that must stop - /// offering legacy services (LP-7). Read per answer, as sign-in reads it. - pub async fn legacy_protocols_off(&self) -> trc::Result { - Ok(self.protocol_policy().await?.legacy_protocols.is_disabled()) + /// Whether 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? { + Some(domain) => match domain.id_tenant { + Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, + None => Ok(false), + }, + None => Ok(false), + } } } @@ -380,28 +461,57 @@ mod tests { #[test] fn refusals_read_as_the_spec_writes_them() { // LP-12, with "Your organization" read as "This server" (LP-6). + let server = RefusalScope::Server; assert_eq!( - LegacyProtocol::Imap.refusal(), + LegacyProtocol::Imap.refusal(server), "This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." ); assert!( LegacyProtocol::Pop3 - .refusal() + .refusal(server) .starts_with("[AUTH] This server allows") ); assert_eq!( - LegacyProtocol::ManageSieve.refusal(), + LegacyProtocol::ManageSieve.refusal(server), "This server allows only INBUXA webmail and JMAP apps." ); assert_eq!( - LegacyProtocol::Submission.refusal(), + LegacyProtocol::Submission.refusal(server), "535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" ); } + #[test] + fn a_tenant_refusal_speaks_for_the_organization() { + // LP-12, exactly as the spec writes them. + let tenant = RefusalScope::Tenant(7); + assert_eq!( + LegacyProtocol::Imap.refusal(tenant), + "Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + ); + assert_eq!( + LegacyProtocol::Pop3.refusal(tenant), + "[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + ); + assert_eq!( + LegacyProtocol::ManageSieve.refusal(tenant), + "Your organization allows only INBUXA webmail and JMAP apps." + ); + assert_eq!( + LegacyProtocol::Submission.refusal(tenant), + "535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" + ); + let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into())); + assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant")); + // IMAP answers the command's tag from Id; the refusal must leave it be. + assert!(err.value(trc::Key::Id).is_none()); + assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused))); + } + #[test] fn a_refusal_is_not_a_failed_sign_in() { - let err = LegacyProtocol::Imap.refused(&basic("maria@Example.org")); + let err = LegacyProtocol::Imap + .refused(RefusalScope::Server, domain_of(&basic("maria@Example.org"))); assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused))); assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed))); // The session stays open: the mail app is told, not thrown off. diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index bb06e86..9d69c95 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -12,3 +12,4 @@ pub mod listeners; pub mod protocol_policy; +pub mod tenant_protocol_policy; diff --git a/crates/features/src/security/tenant_protocol_policy.rs b/crates/features/src/security/tenant_protocol_policy.rs new file mode 100644 index 0000000..4bdd93d --- /dev/null +++ b/crates/features/src/security/tenant_protocol_policy.rs @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:TenantProtocolPolicy`, one tenant's legacy mail protocols switch +//! (legacy-protocols spec, LP-9 to LP-14a). Stored as JSON under `P` `t` and +//! the tenant id in the fork's subspace; a tenant with nothing stored has +//! legacy protocols on. +//! +//! 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 serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +/// One tenant's switch. +#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TenantProtocolPolicy { + /// The switch itself. + pub legacy_protocols: LegacyProtocols, + /// 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). +/// +/// 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.", + ) +} + +fn key(tenant_id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.extend_from_slice(b"Pt"); + key.extend_from_slice(&tenant_id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +struct Json(TenantProtocolPolicy); + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +/// The tenant's policy, or the default (on) when it has never been set. +pub async fn get(data: &Store, tenant_id: u32) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key(tenant_id))) + .await + .caused_by(trc::location!())? + .map(|Json(policy)| policy) + .unwrap_or_default()) +} + +/// Stores the tenant's policy. +pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) -> trc::Result<()> { + let bytes = serde_json::to_vec(policy).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?; + let mut batch = BatchBuilder::new(); + batch.set(key(tenant_id), bytes); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(legacy_protocols: LegacyProtocols) -> ProtocolPolicy { + ProtocolPolicy { + legacy_protocols, + ..Default::default() + } + } + + #[test] + fn a_tenant_starts_with_legacy_protocols_on() { + assert!( + !TenantProtocolPolicy::default() + .legacy_protocols + .is_disabled() + ); + } + + #[test] + fn a_tenant_can_always_turn_them_off() { + for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] { + assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), 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!(why.contains("inbuxa:ProtocolPolicy"), "{why}"); + } + + #[test] + fn keys_are_per_tenant_and_clear_of_the_server_policy() { + let ValueClass::Any(a) = key(1) else { panic!() }; + let ValueClass::Any(b) = key(2) else { panic!() }; + assert_ne!(a.key, b.key); + assert_eq!(&a.key[..2], b"Pt"); + assert_ne!(a.key, b"Pp".to_vec()); + } + + #[test] + fn stored_json_reads_back() { + let policy = TenantProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + changed_at: Some(1), + changed_by: Some("b".into()), + }; + let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap(); + assert_eq!(back, policy); + // Unknown and missing fields read as defaults. + let Json(back) = Json::deserialize(br#"{"futureField":1}"#).unwrap(); + assert_eq!(back, TenantProtocolPolicy::default()); + } +} diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index e68f9d1..d2fbad7 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -100,6 +100,12 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; + // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + self.server + .refuse_legacy_session(LegacyProtocol::Imap, &access_token) + .await + .map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?; + // Enforce concurrency limits let in_flight = match access_token.is_imap_request_allowed() { LimiterResult::Allowed(in_flight) => Some(in_flight), diff --git a/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs new file mode 100644 index 0000000..10d6fe9 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:TenantProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: one +//! tenant's legacy mail protocols switch (legacy-protocols spec, LP-9 to +//! LP-14). One per tenant; its id is the tenant's id. +//! +//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client +//! that sets them is answered with `invalidProperties`. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct TenantProtocolPolicy; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TenantProtocolPolicyProperty { + Id, + /// Server-set: the tenant this is the switch of. + TenantId, + /// The switch: `enabled` or `disabled`. + LegacyProtocols, + ChangedAt, + ChangedBy, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TenantProtocolPolicyValue { + Id(Id), +} + +impl Property for TenantProtocolPolicyProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + TenantProtocolPolicyProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + TenantProtocolPolicyProperty::Id => "id", + TenantProtocolPolicyProperty::TenantId => "tenantId", + TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", + TenantProtocolPolicyProperty::ChangedAt => "changedAt", + TenantProtocolPolicyProperty::ChangedBy => "changedBy", + } + .into() + } +} + +impl TenantProtocolPolicyProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => TenantProtocolPolicyProperty::Id, + b"tenantId" => TenantProtocolPolicyProperty::TenantId, + b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols, + b"changedAt" => TenantProtocolPolicyProperty::ChangedAt, + b"changedBy" => TenantProtocolPolicyProperty::ChangedBy, + ) + } +} + +impl TenantProtocolPolicyProperty { + /// Whether this property is the server's to say. A client that sets one + /// is answered with `invalidProperties`. + pub fn is_server_set(&self) -> bool { + matches!( + self, + TenantProtocolPolicyProperty::TenantId + | TenantProtocolPolicyProperty::ChangedAt + | TenantProtocolPolicyProperty::ChangedBy + ) + } +} + +impl FromStr for TenantProtocolPolicyProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + TenantProtocolPolicyProperty::parse(s).ok_or(()) + } +} + +impl Element for TenantProtocolPolicyValue { + type Property = TenantProtocolPolicyProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(TenantProtocolPolicyProperty::Id) => { + Id::from_str(value).ok().map(TenantProtocolPolicyValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + TenantProtocolPolicyValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for TenantProtocolPolicy { + type Property = TenantProtocolPolicyProperty; + + type Element = TenantProtocolPolicyValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = TenantProtocolPolicyProperty::Id; +} + +impl From for TenantProtocolPolicyValue { + fn from(id: Id) -> Self { + TenantProtocolPolicyValue::Id(id) + } +} + +impl JmapObjectId for TenantProtocolPolicyValue { + fn as_id(&self) -> Option { + match self { + TenantProtocolPolicyValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + TenantProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = TenantProtocolPolicyValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for TenantProtocolPolicyProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index b61ac6f..50d17b6 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -23,6 +23,7 @@ pub mod email_submission; pub mod fastmail_masked_email; // inbuxa: masked email pub mod inbuxa_ai_limits; // inbuxa: AI spam classification pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off +pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod file_node; pub mod identity; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 860525b..7a62d74 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -64,6 +64,9 @@ impl Response<'_> { GetResponseMethod::ProtocolPolicy(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::TenantProtocolPolicy(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 78d6ce3..84d1f27 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -47,6 +47,9 @@ impl Response<'_> { GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, + GetRequestMethod::TenantProtocolPolicy(request) => { + request.resolve_references(self)? + } GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -93,6 +96,9 @@ impl Response<'_> { SetRequestMethod::ProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::TenantProtocolPolicy(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 42c0a10..a6baff3 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -50,6 +50,7 @@ pub enum MethodObject { // inbuxa: AI call limits AiLimits, ProtocolPolicy, + TenantProtocolPolicy, } impl MethodObject { @@ -77,6 +78,7 @@ impl MethodObject { MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, + MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } } } @@ -256,6 +258,12 @@ impl MethodName { (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get", (MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set", + (MethodFunction::Get, MethodObject::TenantProtocolPolicy) => { + "inbuxa:TenantProtocolPolicy/get" + } + (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => { + "inbuxa:TenantProtocolPolicy/set" + } (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -383,6 +391,8 @@ impl MethodName { "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), + "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), + "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -437,6 +447,7 @@ impl Display for MethodObject { MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", + MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index cdca15d..dfb7bfe 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -117,6 +117,9 @@ pub enum GetRequestMethod { DeletedAccount(Box>), AiLimits(Box>), ProtocolPolicy(Box>), + TenantProtocolPolicy( + Box>, + ), } #[derive(Debug)] @@ -141,6 +144,9 @@ pub enum SetRequestMethod<'x> { DeletedAccount(Box>), AiLimits(Box>), ProtocolPolicy(Box>), + TenantProtocolPolicy( + Box>, + ), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 381eace..3ad9d05 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -176,6 +176,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::TenantProtocolPolicy) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Get(GetRequestMethod::TenantProtocolPolicy(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -348,6 +357,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Set(SetRequestMethod::TenantProtocolPolicy(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 9ab4de8..5caca70 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -104,6 +104,9 @@ pub enum GetResponseMethod { DeletedAccount(GetResponse), AiLimits(GetResponse), ProtocolPolicy(GetResponse), + TenantProtocolPolicy( + GetResponse, + ), } #[derive(Debug, serde::Serialize)] @@ -129,6 +132,9 @@ pub enum SetResponseMethod { DeletedAccount(Box>), AiLimits(Box>), ProtocolPolicy(Box>), + TenantProtocolPolicy( + Box>, + ), } #[derive(Debug, serde::Serialize)] @@ -305,6 +311,26 @@ impl<'x> From } } +impl<'x> From> + for ResponseMethod<'x> +{ + fn from( + value: GetResponse, + ) -> Self { + ResponseMethod::Get(GetResponseMethod::TenantProtocolPolicy(value)) + } +} + +impl<'x> From> + for ResponseMethod<'x> +{ + fn from( + value: SetResponse, + ) -> Self { + ResponseMethod::Set(SetResponseMethod::TenantProtocolPolicy(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::AiLimits(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index baa73fa..0154cf8 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -80,6 +80,10 @@ impl JmapAuthorization for AccessToken { // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, + // inbuxa: legacy protocols off, per tenant. It governs + // sign-in on the tenant's domains, so it takes the domain's + // permissions, which a tenant administrator already holds. + GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -184,6 +188,14 @@ impl JmapAuthorization for AccessToken { Permission::SysNetworkListenerUpdate, Permission::SysNetworkListenerUpdate, ), + // inbuxa: legacy protocols off, per tenant, with the domain's + SetRequestMethod::TenantProtocolPolicy(s) => validate_set( + s, + self, + Permission::SysDomainUpdate, + Permission::SysDomainUpdate, + Permission::SysDomainUpdate, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -294,7 +306,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::MaskedEmail | MethodObject::DeletedAccount | MethodObject::AiLimits - | MethodObject::ProtocolPolicy => Permission::JmapEmailChanges, + | MethodObject::ProtocolPolicy + | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index b521727..3a5b3fb 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -224,6 +224,9 @@ impl RequestHandler for Server { SetResponseMethod::ProtocolPolicy(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::TenantProtocolPolicy(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -386,6 +389,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:TenantProtocolPolicy/get (legacy protocols off, per tenant) + GetRequestMethod::TenantProtocolPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::tenant_protocol_policy::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -634,6 +644,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant) + SetRequestMethod::TenantProtocolPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::tenant_protocol_policy::set(self, access_token, *req) + .await? + .into() + } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 9f091ae..c35b3f4 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -419,6 +419,7 @@ impl IntermediateChangesResponse { | MethodObject::DeletedAccount | MethodObject::AiLimits | MethodObject::ProtocolPolicy + | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index a04c2c5..fa78c8e 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -10,6 +10,7 @@ pub mod access; pub mod ai_limits; pub mod protocol_policy; +pub mod tenant_protocol_policy; pub mod deleted_account; pub mod fastmail; pub mod masked_email; diff --git a/crates/jmap/src/inbuxa/tenant_protocol_policy.rs b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs new file mode 100644 index 0000000..840e817 --- /dev/null +++ b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:TenantProtocolPolicy/get` and `/set`: one tenant's legacy mail +//! protocols switch (legacy-protocols spec, LP-9 to LP-14). There is one per +//! tenant, and its id is the tenant's. +//! +//! Inside a tenant, a principal reaches only its own tenant's (MT-1): `/get` +//! 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). +//! A tenant's switch closes no port (LP-13) -- sign-in and client +//! configuration read it (LP-10, LP-14a). + +use common::{Server, auth::AccessToken}; +use inbuxa_features::{ + security::{ + protocol_policy::LegacyProtocols, + tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal}, + }, + tenancy::quota::all_tenants, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_tenant_protocol_policy::{ + TenantProtocolPolicy, TenantProtocolPolicyProperty as P, TenantProtocolPolicyValue, + }, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use types::id::Id; + +type PValue = Value<'static, P, TenantProtocolPolicyValue>; + +const ALL: &[P] = &[ + P::Id, + P::TenantId, + P::LegacyProtocols, + P::ChangedAt, + P::ChangedBy, +]; + +/// The tenants this principal may reach: its own inside a tenant (MT-1), +/// every tenant at server level. +async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result> { + match access_token.tenant_id() { + Some(tenant_id) => Ok(vec![tenant_id]), + None => all_tenants(server.registry()).await, + } +} + +fn to_value(tenant_id: u32, policy: &Policy, properties: &[P]) -> PValue { + 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::ChangedAt => policy + .changed_at + .map(|at| Value::Number(at.into())) + .unwrap_or(Value::Null), + P::ChangedBy => policy + .changed_by + .as_ref() + .map(|by| Value::Str(by.clone().into())) + .unwrap_or(Value::Null), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:TenantProtocolPolicy/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + + let reachable = reachable(server, access_token).await?; + let wanted = match ids { + None => reachable.iter().map(|id| Id::from(*id)).collect(), + Some(ids) => ids, + }; + for id in wanted { + let tenant_id = id.document_id(); + if reachable.contains(&tenant_id) { + let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?; + response + .list + .push(to_value(tenant_id, &policy, &properties)); + } else { + response.push_not_found(id); + } + } + Ok(response) +} + +/// `inbuxa:TenantProtocolPolicy/set`: turns one tenant's switch. Unset +/// (`null`) puts legacy protocols back on, which LP-9 may refuse. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, TenantProtocolPolicy>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + // A tenant's switch comes and goes with the tenant; it is only turned. + for (client_id, _) in request.unwrap_create() { + response.not_created.append( + client_id, + SetError::forbidden().with_description("A tenant's switch exists with the tenant."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("A tenant's switch exists with the tenant."), + ); + } + + let reachable = reachable(server, access_token).await?; + for (id, value) in request.unwrap_update().into_valid() { + let tenant_id = id.document_id(); + if !reachable.contains(&tenant_id) { + response.not_updated.append(id, SetError::not_found()); + continue; + } + + let data = &server.core.storage.data; + let previous = tenant_protocol_policy::get(data, tenant_id).await?; + let mut policy = previous.clone(); + let mut error = None; + for (key, value) in value.into_expanded_object() { + let result = match &key { + Key::Property(P::LegacyProtocols) => match value { + Value::Null => { + policy.legacy_protocols = LegacyProtocols::Enabled; + Ok(()) + } + value => match value.as_str().as_deref() { + Some("enabled") => { + policy.legacy_protocols = LegacyProtocols::Enabled; + Ok(()) + } + 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"), + }; + if let Err(why) = result { + error = Some( + SetError::invalid_properties() + .with_property(key.into_owned()) + .with_description(why), + ); + break; + } + } + if let Some(error) = error { + response.not_updated.append(id, error); + continue; + } + + // LP-9: server off means off for everyone. + if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) { + response + .not_updated + .append(id, SetError::forbidden().with_description(why)); + continue; + } + + if policy.legacy_protocols != previous.legacy_protocols { + 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?; + + // LP-14. A tenant's switch closes and reopens nothing (LP-13). + trc::event!( + Security(trc::SecurityEvent::LegacyProtocolsChanged), + Policy = "tenant", + Id = tenant_id, + Value = if policy.legacy_protocols.is_disabled() { + "disabled" + } else { + "enabled" + }, + AccountId = policy.changed_by.clone(), + ); + } + response.updated.append(id, None); + } + Ok(response) +} diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 635443f..3d7263d 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -101,6 +101,11 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?; + // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + self.server + .refuse_legacy_session(LegacyProtocol::ManageSieve, &access_token) + .await?; + // Enforce concurrency limits let in_flight = match access_token.is_imap_request_allowed() { LimiterResult::Allowed(in_flight) => Some(in_flight), diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index 1a7c832..1c885e3 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -99,6 +99,11 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?; + // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + self.server + .refuse_legacy_session(LegacyProtocol::Pop3, &access_token) + .await?; + // Enforce concurrency limits let in_flight = match access_token.is_imap_request_allowed() { LimiterResult::Allowed(in_flight) => Some(in_flight), diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index b6c5b3e..aa68fd8 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -121,16 +121,7 @@ impl Session { .refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials) .await { - let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused)); - trc::error!(err.span_id(self.data.session_id)); - if refused { - self.write(LegacyProtocol::Submission.refusal().as_bytes()) - .await?; - } else { - self.write(b"454 4.7.0 Temporary authentication failure\r\n") - .await?; - } - return Ok(false); + return self.legacy_refusal(err).await; } // Authenticate @@ -144,6 +135,17 @@ impl Session { .await .and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend)); + // inbuxa: legacy-protocols LP-10, for a bearer token that named no + // account and so couldn't be judged by its domain beforehand. + if let Ok(access_token) = &result + && let Err(err) = self + .server + .refuse_legacy_session(LegacyProtocol::Submission, access_token) + .await + { + return self.legacy_refusal(err).await; + } + let result = match result { Ok(access_token) => self.server.account_info(access_token.account_id()).await, Err(err) => Err(err), @@ -207,6 +209,26 @@ impl Session { Ok(false) } + /// inbuxa: legacy-protocols LP-6, LP-10. A refusal is written with the + /// words the error carries, which know whose switch refused; anything + /// else that went wrong deciding is a temporary failure. Neither counts + /// as an authentication error (LP-11). + async fn legacy_refusal(&mut self, err: trc::Error) -> Result { + let reply = err + .matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused)) + .then(|| err.value_as_str(trc::Key::Details).map(str::to_string)) + .flatten(); + trc::error!(err.span_id(self.data.session_id)); + match reply { + Some(reply) => self.write(reply.as_bytes()).await?, + None => { + self.write(b"454 4.7.0 Temporary authentication failure\r\n") + .await? + } + } + Ok(false) + } + pub async fn auth_error(&mut self, response: &[u8]) -> Result { tokio::time::sleep(self.params.auth_errors_wait).await; self.data.auth_errors += 1; diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index f74e614..26ab77e 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -23,6 +23,14 @@ and the suggested zone marks their SRV names not offered (LP-7, test 5). Every change of the switch, and every refused sign-in, is an event in the server's log (LP-8, test 14; LP-6). +Then a tenant's own switch (LP-9 to LP-14a): a tenant administrator turns it +off for its tenant, which refuses sign-in on the tenant's domains -- real +address or made-up, right password or wrong -- in the organization's words, +leaves every other domain alone, and stops client configuration offering +legacy servers for those domains. It reaches only its own tenant's switch, +and can't turn it back on while the server has legacy protocols off +(acceptance tests 6 to 10, 14). + Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. """ @@ -234,6 +242,127 @@ def events(name): return [l for l in (out.stdout + out.stderr).splitlines() if f"({name})" in l] +def pop3_login(port, user, password): + """The reply to PASS, over implicit TLS.""" + with tls(port) as sock: + read = lines(sock) + next(read) # greeting + sock.sendall(f"USER {user}\r\n".encode()) + next(read) + sock.sendall(f"PASS {password}\r\n".encode()) + return next(read, "") + + +def created(res, key, what): + obj = (res[1].get("created") or {}).get(key) + if not obj: + sys.exit(f"creating {what} failed: " + json.dumps(res[1])[:600]) + return obj["id"] + + +def tenant_checks(admin, admin_pw, account): + """LP-9 to LP-14a, on a tenant with its own domain, user and admin.""" + t = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t"}}}), + "t", "tenant") + t2 = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t2"}}}), + "t", "second tenant") + domain = created(one(admin, admin_pw, "x:Domain/set", {"create": {"d": { + "name": "t.legacy.test", "isEnabled": True, "memberTenantId": t, + "certificateManagement": {"@type": "Manual"}, "dnsManagement": {"@type": "Manual"}, + "dkimManagement": {"@type": "Manual"}}}}), "d", "tenant domain") + user_pw = secret_file("legacy-tenant-user") + tadmin_pw = secret_file("legacy-tenant-admin") + def user(name, password, extra=None): + body = {"@type": "User", "name": name, "domainId": domain, + "credentials": {"0": {"@type": "Password", "secret": password}}} + body.update(extra or {}) + return created(one(admin, admin_pw, "x:Account/set", {"create": {"a": body}}), + "a", f"account {name}") + user("u", user_pw) + user("tadmin", tadmin_pw, {"roles": {"@type": "Admin"}}) + tu, ta = "u@t.legacy.test", "tadmin@t.legacy.test" + + tsess = session(ta, tadmin_pw) + tacct = tsess["primaryAccounts"].get(INBUXA) or list(tsess["accounts"])[0] + tget = lambda ids=None: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/get", + {"accountId": tacct, "ids": ids}) + tset = lambda value: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", + {"accountId": tacct, "update": {t: {"legacyProtocols": value}}}) + + # Before: the tenant's user signs in, and its domain is offered IMAP. + check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), + "a tenant's user signs in over IMAP with the tenant's switch on") + got = tget() + mine = [p["id"] for p in got[1].get("list", [])] + check(got[0] == "inbuxa:TenantProtocolPolicy/get" and mine == [t], + "a tenant admin's /get answers with its own tenant's switch only (test 10)") + if mine != [t]: + print(" reply:", json.dumps(got)[:400]) + got = tget([t2]) + check(got[1].get("notFound") == [t2], "another tenant's switch is not found (test 10, MT-1)") + res = one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", + {"accountId": tacct, "update": {t2: {"legacyProtocols": "disabled"}}}) + check(t2 in (res[1].get("notUpdated") or {}), "nor can it be changed (test 10)") + + # The tenant admin turns it off for its tenant (LP-9). + res = tset("disabled") + check(t in (res[1].get("updated") or {}), "a tenant admin turns legacy protocols off (LP-9)") + if t not in (res[1].get("updated") or {}): + print(" reply:", json.dumps(res)[:400]) + check(events_matching("security.legacy-protocols-changed", 'policy = "tenant"', + 'value = "disabled"'), + "and it is an event, scope tenant (LP-14, test 14)") + + # Refused on the tenant's domain, every way in the same words (tests 6-8). + imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. " + "This mail app can't sign in.") + check(imap_login(PORTS["imap"], tu, user_pw) == imap_no, + "the tenant's user is refused over IMAP with the right password (test 6)") + check(imap_login(PORTS["imap"], tu, "wrong") == imap_no, "and with a wrong one (test 6)") + check(imap_login(PORTS["imap"], "nobody@t.legacy.test", "x") == imap_no, + "and a made-up address on the domain gets the same (test 7)") + 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.", "POP3 refuses in its own form (test 8)") + check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] == + "535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. " + "This mail app can't send.", "submission refuses in its own form (test 8)") + check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), + "an account on another domain signs in over IMAP normally (test 6)") + check(session(tu, user_pw).get("accounts"), "the tenant's user still has JMAP (test 8)") + check(not events("auth.failed"), "no refusal counted as a failed sign-in (LP-11)") + + # Client configuration for the tenant's domain only (LP-14a). + with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress={tu}", timeout=30) as r: + tenant_cfg = r.read().decode() + with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress={admin}", timeout=30) as r: + other_cfg = r.read().decode() + check('type="imap"' not in tenant_cfg and 'type="imap"' in other_cfg, + "autoconfig offers no IMAP for the tenant's domain, and still does elsewhere (LP-14a)") + + # Server off means off for everyone: the tenant can't turn it back on (test 9). + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": {"legacyProtocols": "disabled"}}}) + res = tset("enabled") + refused = (res[1].get("notUpdated") or {}).get(t) or {} + check(refused.get("type") == "forbidden" + and "inbuxa:ProtocolPolicy" in (refused.get("description") or ""), + "with the server off, the tenant can't turn them back on (LP-9, test 9)") + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": {"legacyProtocols": "enabled"}}}) + check(settle(PORTS["imap"], True), "IMAP is back after the server switch returns") + + # And back on, the tenant's user signs in again. + res = tset("enabled") + check(t in (res[1].get("updated") or {}), "with the server on, the tenant turns them back on") + check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), + "and its user signs in over IMAP again") + + +def events_matching(name, *parts): + return any(all(p in line for p in parts) for line in events(name)) + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -444,6 +573,9 @@ def main(): check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], "autoconfig and the suggested zone offer them again once back on") + # A tenant's own switch (LP-9 to LP-14a). + tenant_checks(admin, admin_pw, account) + # And sign-in works again, with no restart. check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), "IMAP sign-in works again once the switch is back on") From cd99037ca4a62990c9c3d512ac4f5a808b732bd9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 11:30:34 -0700 Subject: [PATCH 4/6] The session says whether legacy protocols are off for the account The urn:inbuxa:jmap capability on the signed-in principal's own account gains legacyProtocols: "enabled" or "disabled", the stricter of the server's switch and the account's tenant's (legacy-protocols spec, Interfaces). It is what the webmail needs to tell someone why their phone's mail app won't connect (LP-19), and it closes acceptance test 13. contract.md's C-1 gains the line. It is an optional field added, which C-3 says doesn't bump the contract version. tests/e2e/legacy_protocols.py reads it back from the session on a running server: enabled for the tenant's user while both switches are on, disabled once its tenant turns legacy protocols off while an account outside the tenant still reads enabled, disabled for everyone while the server switch is off, and enabled again at the end. All 67 checks pass. --- crates/common/src/network/legacy.rs | 17 +++++++++++++++++ crates/jmap-proto/src/request/capability.rs | 5 +++++ crates/jmap/src/api/session.rs | 11 ++++++++++- docs/spec/contract.md | 6 ++++++ tests/e2e/legacy_protocols.py | 21 ++++++++++++++++++++- 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index d3e17b8..8992b5a 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -403,6 +403,23 @@ impl Server { Ok(()) } + /// 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 + /// 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( + &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), + } + } + /// 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( diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 7096b19..d9779d8 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -142,6 +142,11 @@ pub struct InbuxaAccountCapabilities { /// The logo that applies to the principal (MT-22): a URL or a data URL. #[serde(rename(serialize = "logo"))] pub logo: Option, + /// Whether legacy mail protocols are `enabled` or `disabled` for the + /// principal: the stricter of the server's switch and its tenant's + /// (legacy-protocols spec, Interfaces; LP-19). + #[serde(rename(serialize = "legacyProtocols"))] + pub legacy_protocols: &'static str, } #[derive(Debug, Clone, serde::Serialize)] diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 95f456a..7df9bc4 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -66,9 +66,18 @@ 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? { + "disabled" + } else { + "enabled" + }; account.account_capabilities.append( Capability::Inbuxa, - Capabilities::Inbuxa(InbuxaAccountCapabilities { logo }), + Capabilities::Inbuxa(InbuxaAccountCapabilities { + logo, + legacy_protocols, + }), ); // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks if access_token.has_permission(Permission::SysMaskedEmailGet) { diff --git a/docs/spec/contract.md b/docs/spec/contract.md index 6fca363..b911cc6 100644 --- a/docs/spec/contract.md +++ b/docs/spec/contract.md @@ -68,6 +68,12 @@ Each has an ID, and tests name the IDs they check. In `accountCapabilities`, the signed-in principal's own account carries `urn:inbuxa:jmap` with `logo`: the logo that applies to it (multi-tenancy MT-22), a string (URL or data URL) or `null`. Added 2026-09-18. + + It also carries `legacyProtocols`: `enabled` or `disabled`, whether IMAP, + POP3, ManageSieve and SMTP submission are off for the principal -- the + stricter of the server's switch and its tenant's (legacy-protocols spec, + Interfaces). A front end uses it to say why a mail app can't connect + (LP-19). Added 2026-09-21. - **C-2.** Each front end states the contract versions it supports and checks `contract` after signing in. Outside its range it stops, with a message naming both versions. For ihasmail-inbuxa this replaces public ihasmail's diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 26ab77e..00a8c01 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -29,7 +29,8 @@ address or made-up, right password or wrong -- in the organization's words, leaves every other domain alone, and stops client configuration offering legacy servers for those domains. It reaches only its own tenant's switch, and can't turn it back on while the server has legacy protocols off -(acceptance tests 6 to 10, 14). +(acceptance tests 6 to 10, 14). Throughout, the JMAP session tells each +account which way its switches point (test 13). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -289,6 +290,9 @@ def tenant_checks(admin, admin_pw, account): tset = lambda value: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", {"accountId": tacct, "update": {t: {"legacyProtocols": value}}}) + check(session_flag(tu, user_pw) == "enabled", + "the session says enabled for the tenant's user while both switches are on (test 13)") + # Before: the tenant's user signs in, and its domain is offered IMAP. check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), "a tenant's user signs in over IMAP with the tenant's switch on") @@ -313,6 +317,11 @@ def tenant_checks(admin, admin_pw, account): 'value = "disabled"'), "and it is an event, scope tenant (LP-14, test 14)") + check(session_flag(tu, user_pw) == "disabled", + "the session says disabled for the tenant's user once its tenant turns it off (test 13)") + check(session_flag(admin, admin_pw) == "enabled", + "and still enabled for an account outside the tenant (test 13)") + # Refused on the tenant's domain, every way in the same words (tests 6-8). imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. " "This mail app can't sign in.") @@ -343,6 +352,8 @@ def tenant_checks(admin, admin_pw, account): # Server off means off for everyone: the tenant can't turn it back on (test 9). one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", {"accountId": account, "update": {"singleton": {"legacyProtocols": "disabled"}}}) + check(session_flag(admin, admin_pw) == "disabled", + "with the server off, the session says disabled for everyone (test 13)") res = tset("enabled") refused = (res[1].get("notUpdated") or {}).get(t) or {} check(refused.get("type") == "forbidden" @@ -357,6 +368,14 @@ def tenant_checks(admin, admin_pw, account): check(t in (res[1].get("updated") or {}), "with the server on, the tenant turns them back on") check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), "and its user signs in over IMAP again") + check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)") + + +def session_flag(user, password): + """legacyProtocols 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("legacyProtocols") def events_matching(name, *parts): From 3f40b3603237d88b42f54b5dd1a8d4098e058a75 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 11:45:05 -0700 Subject: [PATCH 5/6] The switch knows who still uses legacy mail apps (LP-15, server) The impact panel's data. Every successful sign-in over IMAP, POP3, ManageSieve or SMTP AUTH records, per account and per protocol, one timestamp -- nothing else: no address, no IP, no client. It is written at most once an hour per account and protocol, so a mail app polling every minute costs a read per sign-in and a write an hour. A record that can't be written is logged and the sign-in goes ahead. Both switches serve it as a read-only property, recentLegacyUse, as wouldClose serves the confirmation: a list of {accountId, name, protocol, lastUsedAt} for sign-ins in the last 30 days, most recent first. inbuxa:ProtocolPolicy lists every account; inbuxa:TenantProtocolPolicy lists only its tenant's own (MT-1). Accounts since deleted are left out. It is computed only when the property is asked for. The recording sits where the tenant check already runs once the account is known, which becomes admit_legacy_session: refuse if the account's tenant has legacy protocols off, otherwise record. A refused sign-in is never recorded. The spec leaves the interface to the implementation; a property on each switch keeps the panel's data behind the same permission as the switch itself, with no new object. Unit tests hold the 30-day window to acceptance test 11 (three days ago listed, forty not), the hourly throttle and the keys. The e2e proves on a running server that the admin's IMAP and submission sign-ins are listed with their time, that a second sign-in within the hour isn't written again, and that a tenant's list holds its own user and nobody outside the tenant. All 70 checks pass. --- crates/common/src/network/legacy.rs | 70 +++++- crates/features/src/security/legacy_use.rs | 220 ++++++++++++++++++ crates/features/src/security/mod.rs | 1 + crates/imap/src/op/authenticate.rs | 5 +- .../src/object/inbuxa_protocol_policy.rs | 6 + .../object/inbuxa_tenant_protocol_policy.rs | 6 + crates/jmap/src/inbuxa/protocol_policy.rs | 56 ++++- .../jmap/src/inbuxa/tenant_protocol_policy.rs | 17 +- crates/managesieve/src/op/authenticate.rs | 5 +- crates/pop3/src/op/authenticate.rs | 5 +- crates/smtp/src/inbound/auth.rs | 5 +- tests/e2e/legacy_protocols.py | 28 ++- 12 files changed, 403 insertions(+), 21 deletions(-) create mode 100644 crates/features/src/security/legacy_use.rs diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index 8992b5a..54dcb50 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -33,6 +33,7 @@ use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor}; use directory::Credentials; use inbuxa_features::security::{ + legacy_use::{self, LegacyUse}, listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, tenant_protocol_policy, @@ -280,6 +281,16 @@ impl LegacyProtocol { } } + /// The same protocol, as the impact panel's record names it (LP-15). + pub fn as_use(&self) -> LegacyUse { + match self { + LegacyProtocol::Imap => LegacyUse::Imap, + LegacyProtocol::Pop3 => LegacyUse::Pop3, + LegacyProtocol::ManageSieve => LegacyUse::ManageSieve, + LegacyProtocol::Submission => LegacyUse::Submission, + } + } + /// What the mail app is told (LP-12). Each protocol's own framing -- /// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session; /// POP3 carries `[AUTH]` in the text, since its errors have no separate @@ -338,6 +349,17 @@ impl LegacyProtocol { } } +/// One account's last sign-in over one legacy protocol, as the impact panel +/// shows it (LP-15). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecentUse { + pub account_id: u32, + pub name: String, + pub protocol: &'static str, + /// Seconds since the epoch. + pub at: u64, +} + /// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefusalScope { @@ -386,11 +408,16 @@ impl Server { Ok(()) } - /// The same, once the account is known (LP-10). A bearer token needn't - /// name an account, so a sign-in with one can't be judged by its domain - /// beforehand; this judges it by the tenant the token turned out to - /// belong to. For a password sign-in it has already been decided. - pub async fn refuse_legacy_session( + /// Once the account is known: refuses it if its tenant has legacy + /// protocols off, and otherwise records the sign-in for the impact panel. + /// + /// The refusal is LP-10 again for a bearer token, which needn't name an + /// account and so can't be judged by its domain beforehand; for a + /// password sign-in it has already been decided. The record is LP-15's: + /// one timestamp per account and protocol, at most hourly. A record that + /// can't be written is logged and the sign-in goes ahead -- a panel is + /// not worth locking anyone out over. + pub async fn admit_legacy_session( &self, protocol: LegacyProtocol, access_token: &AccessToken, @@ -400,9 +427,42 @@ impl Server { { return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None)); } + if let Err(err) = legacy_use::record( + &self.core.storage.data, + access_token.account_id(), + protocol.as_use(), + store::write::now(), + ) + .await + { + trc::error!(err.details("Failed to record a legacy sign-in (LP-15).")); + } Ok(()) } + /// Who signed in over a legacy protocol in the last 30 days, most recent + /// first, for the impact panel (LP-15): everyone at server scope, or one + /// tenant's accounts. Accounts that no longer exist are left out. + pub async fn recent_legacy_use(&self, tenant_id: Option) -> trc::Result> { + let mut recent = Vec::new(); + for entry in legacy_use::recent(&self.core.storage.data, store::write::now()).await? { + let Some(account) = self.try_account(entry.account_id).await? else { + continue; + }; + if tenant_id.is_some() && account.id_tenant != tenant_id { + continue; + } + recent.push(RecentUse { + account_id: entry.account_id, + name: account.name.to_string(), + protocol: entry.protocol.as_str(), + at: entry.at, + }); + } + recent.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.name.cmp(&b.name))); + 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 /// account's apps (legacy-protocols spec, Interfaces), so the webmail can diff --git a/crates/features/src/security/legacy_use.rs b/crates/features/src/security/legacy_use.rs new file mode 100644 index 0000000..28e725a --- /dev/null +++ b/crates/features/src/security/legacy_use.rs @@ -0,0 +1,220 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! When each account last signed in over each legacy protocol, for the +//! impact panel (legacy-protocols spec, LP-15, "Last use per protocol"). +//! +//! One timestamp per account per protocol, and nothing else: no address, no +//! IP, no client. It is written at most once an hour per account and +//! protocol, so a mail app polling every minute costs one read per sign-in +//! and one write an hour. Stored under `P` `u`, the account id and a protocol +//! byte, in the fork's subspace. + +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +/// How long a recorded use stands before the next sign-in rewrites it. +pub const WRITE_EVERY_SECS: u64 = 3600; + +/// How far back the impact panel looks (LP-15). +pub const RECENT_SECS: u64 = 30 * 24 * 3600; + +/// The protocols the panel names, as they are spelled over JMAP. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LegacyUse { + Imap, + Pop3, + ManageSieve, + Submission, +} + +impl LegacyUse { + pub fn as_str(&self) -> &'static str { + match self { + LegacyUse::Imap => "imap", + LegacyUse::Pop3 => "pop3", + LegacyUse::ManageSieve => "manageSieve", + LegacyUse::Submission => "submission", + } + } + + fn byte(&self) -> u8 { + match self { + LegacyUse::Imap => b'i', + LegacyUse::Pop3 => b'p', + LegacyUse::ManageSieve => b's', + LegacyUse::Submission => b'm', + } + } + + fn from_byte(byte: u8) -> Option { + match byte { + b'i' => Some(LegacyUse::Imap), + b'p' => Some(LegacyUse::Pop3), + b's' => Some(LegacyUse::ManageSieve), + b'm' => Some(LegacyUse::Submission), + _ => None, + } + } +} + +/// One account's last use of one protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Use { + pub account_id: u32, + pub protocol: LegacyUse, + /// Seconds since the epoch. + pub at: u64, +} + +fn key(account_id: u32, protocol: Option) -> ValueKey { + let mut key = Vec::with_capacity(7); + key.extend_from_slice(b"Pu"); + key.extend_from_slice(&account_id.to_be_bytes()); + key.push(protocol.map_or(0, |p| p.byte())); + ValueKey::from(ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + })) +} + +/// Reads a stored key back into who and what, if it is one of ours. +fn parse_key(key: &[u8]) -> Option<(u32, LegacyUse)> { + // The iterator may or may not hand back the subspace byte; the tail is + // what identifies an entry: two bytes of prefix, four of account id and + // one of protocol. + let tail = key.get(key.len().checked_sub(7)?..)?; + (tail[..2] == *b"Pu").then_some(())?; + let account_id = u32::from_be_bytes(tail[2..6].try_into().ok()?); + Some((account_id, LegacyUse::from_byte(tail[6])?)) +} + +struct At(u64); + +impl Deserialize for At { + fn deserialize(bytes: &[u8]) -> trc::Result { + bytes + .try_into() + .map(|bytes| At(u64::from_be_bytes(bytes))) + .map_err(|_| trc::StoreEvent::DataCorruption.caused_by(trc::location!())) + } +} + +/// Whether a use at `at` is recent enough for the panel at `now` (LP-15). +pub fn is_recent(at: u64, now: u64) -> bool { + at >= now.saturating_sub(RECENT_SECS) +} + +/// Whether a use at `now` should be written over one stored at `stored`. +fn due(stored: Option, now: u64) -> bool { + stored.is_none_or(|stored| now.saturating_sub(stored) >= WRITE_EVERY_SECS) +} + +/// Records a successful sign-in, unless one was recorded within the hour. +pub async fn record( + data: &Store, + account_id: u32, + protocol: LegacyUse, + now: u64, +) -> trc::Result<()> { + let stored = data + .get_value::(key(account_id, Some(protocol))) + .await + .caused_by(trc::location!())? + .map(|At(at)| at); + if !due(stored, now) { + return Ok(()); + } + let mut batch = BatchBuilder::new(); + batch.set( + key(account_id, Some(protocol)).class, + now.to_be_bytes().to_vec(), + ); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// Every use recent at `now` (LP-15), across all accounts. +pub async fn recent(data: &Store, now: u64) -> trc::Result> { + let mut uses = Vec::new(); + data.iterate( + IterateParams::new(key(0, None), key(u32::MAX, Some(LegacyUse::Submission))).ascending(), + |key, value| { + if let Some((account_id, protocol)) = parse_key(key) + && let Ok(At(at)) = At::deserialize(value) + && is_recent(at, now) + { + uses.push(Use { + account_id, + protocol, + at, + }); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(uses) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn written_at_most_once_an_hour() { + assert!(due(None, 100)); + assert!(!due(Some(100), 100 + WRITE_EVERY_SECS - 1)); + assert!(due(Some(100), 100 + WRITE_EVERY_SECS)); + // A clock that went backwards doesn't write. + assert!(!due(Some(100), 50)); + } + + #[test] + fn the_panel_looks_back_thirty_days() { + // Acceptance test 11: three days ago is listed, forty days ago isn't. + let now = 1_800_000_000; + let day = 24 * 3600; + assert!(is_recent(now - 3 * day, now)); + assert!(is_recent(now - 30 * day, now)); + assert!(!is_recent(now - 30 * day - 1, now)); + assert!(!is_recent(now - 40 * day, now)); + } + + #[test] + fn keys_read_back() { + for protocol in [ + LegacyUse::Imap, + LegacyUse::Pop3, + LegacyUse::ManageSieve, + LegacyUse::Submission, + ] { + let ValueClass::Any(any) = key(42, Some(protocol)).class else { + panic!() + }; + assert_eq!(parse_key(&any.key), Some((42, protocol))); + // With the subspace byte in front, too. + let mut with_subspace = vec![SUBSPACE_INBUXA]; + with_subspace.extend_from_slice(&any.key); + assert_eq!(parse_key(&with_subspace), Some((42, protocol))); + } + assert_eq!(parse_key(b"Pp"), None); + assert_eq!(parse_key(b"Xx\0\0\0\x2ai"), None); + } + + #[test] + fn only_protocols_are_recorded() { + // Nothing but the four legacy protocols has a byte of its own. + assert_eq!(LegacyUse::from_byte(0), None); + assert_eq!(LegacyUse::from_byte(b'x'), None); + } +} diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index 9d69c95..fb3e583 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -10,6 +10,7 @@ //! ships. The legacy-protocols switch is INBUXA's own design, specified in //! `legacy-protocols.md`. +pub mod legacy_use; pub mod listeners; pub mod protocol_policy; pub mod tenant_protocol_policy; diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index d2fbad7..5391920 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -100,9 +100,10 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; - // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + // inbuxa: legacy-protocols LP-10 for a bearer token that named no + // account, and LP-15: the sign-in is recorded for the impact panel self.server - .refuse_legacy_session(LegacyProtocol::Imap, &access_token) + .admit_legacy_session(LegacyProtocol::Imap, &access_token) .await .map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?; diff --git a/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs b/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs index 66b251f..2430743 100644 --- a/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs +++ b/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs @@ -37,6 +37,9 @@ pub enum ProtocolPolicyProperty { /// Server-set: exactly which listeners turning the switch would close, /// by name and port, for the confirmation (LP-16). WouldClose, + /// Server-set: who signed in over a legacy protocol in the last 30 + /// days, and when, for the impact panel (LP-15). + RecentLegacyUse, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -57,6 +60,7 @@ impl Property for ProtocolPolicyProperty { ProtocolPolicyProperty::SavedListeners => "savedListeners", ProtocolPolicyProperty::ChangedAt => "changedAt", ProtocolPolicyProperty::ChangedBy => "changedBy", + ProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse", ProtocolPolicyProperty::LockedProtocols => "lockedProtocols", ProtocolPolicyProperty::WouldClose => "wouldClose", } @@ -73,6 +77,7 @@ impl ProtocolPolicyProperty { b"savedListeners" => ProtocolPolicyProperty::SavedListeners, b"changedAt" => ProtocolPolicyProperty::ChangedAt, b"changedBy" => ProtocolPolicyProperty::ChangedBy, + b"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse, b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols, b"wouldClose" => ProtocolPolicyProperty::WouldClose, ) @@ -88,6 +93,7 @@ impl ProtocolPolicyProperty { ProtocolPolicyProperty::SavedListeners | ProtocolPolicyProperty::ChangedAt | ProtocolPolicyProperty::ChangedBy + | ProtocolPolicyProperty::RecentLegacyUse | ProtocolPolicyProperty::LockedProtocols | ProtocolPolicyProperty::WouldClose ) 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 10d6fe9..a6e0123 100644 --- a/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs +++ b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs @@ -28,6 +28,9 @@ pub enum TenantProtocolPolicyProperty { LegacyProtocols, ChangedAt, ChangedBy, + /// Server-set: who signed in over a legacy protocol in the last 30 + /// days, and when, for the impact panel (LP-15). + RecentLegacyUse, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -47,6 +50,7 @@ impl Property for TenantProtocolPolicyProperty { TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", TenantProtocolPolicyProperty::ChangedAt => "changedAt", TenantProtocolPolicyProperty::ChangedBy => "changedBy", + TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse", } .into() } @@ -60,6 +64,7 @@ impl TenantProtocolPolicyProperty { b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols, b"changedAt" => TenantProtocolPolicyProperty::ChangedAt, b"changedBy" => TenantProtocolPolicyProperty::ChangedBy, + b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse, ) } } @@ -73,6 +78,7 @@ impl TenantProtocolPolicyProperty { TenantProtocolPolicyProperty::TenantId | TenantProtocolPolicyProperty::ChangedAt | TenantProtocolPolicyProperty::ChangedBy + | TenantProtocolPolicyProperty::RecentLegacyUse ) } } diff --git a/crates/jmap/src/inbuxa/protocol_policy.rs b/crates/jmap/src/inbuxa/protocol_policy.rs index e60129f..6311b62 100644 --- a/crates/jmap/src/inbuxa/protocol_policy.rs +++ b/crates/jmap/src/inbuxa/protocol_policy.rs @@ -19,7 +19,11 @@ //! (LP-4). use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult}; -use common::{Server, auth::AccessToken, network::legacy::PolicyChange}; +use common::{ + Server, + auth::AccessToken, + network::legacy::{PolicyChange, RecentUse}, +}; use inbuxa_features::security::{ listeners, protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener}, @@ -50,6 +54,7 @@ const ALL: &[P] = &[ P::ChangedBy, P::LockedProtocols, P::WouldClose, + P::RecentLegacyUse, ]; fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> { @@ -86,7 +91,12 @@ fn listener_value(listener: &SavedListener) -> PValue { Value::Object(out) } -fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> PValue { +fn to_value( + policy: &Policy, + would_close: &[SavedListener], + recent: &[RecentUse], + properties: &[P], +) -> PValue { let mut out = Map::with_capacity(properties.len()); for property in properties { let value = match property { @@ -127,12 +137,45 @@ fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> // port, so the confirmation can say so before anything happens // (LP-16). P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()), + // Who would notice, before anything changes (LP-15). + P::RecentLegacyUse => recent_value(recent, |id| ProtocolPolicyValue::Id(Id::from(id))), }; out.insert_unchecked(Key::Property(property.clone()), value); } Value::Object(out) } +/// The impact panel's list (LP-15): who, over what, and when, in +/// milliseconds as `changedAt` is. Shared with the tenant's switch. +pub(crate) fn recent_value( + recent: &[RecentUse], + id: impl Fn(u32) -> V, +) -> Value<'static, Pr, V> +where + Pr: jmap_tools::Property, + V: jmap_tools::Element, +{ + Value::Array( + recent + .iter() + .map(|entry| { + let mut out = Map::with_capacity(4); + out.insert_unchecked( + Key::Borrowed("accountId"), + Value::Element(id(entry.account_id)), + ); + out.insert_unchecked(Key::Borrowed("name"), Value::Str(entry.name.clone().into())); + out.insert_unchecked(Key::Borrowed("protocol"), Value::Str(entry.protocol.into())); + out.insert_unchecked( + Key::Borrowed("lastUsedAt"), + Value::Number((entry.at * 1000).into()), + ); + Value::Object(out) + }) + .collect(), + ) +} + /// The listeners turning the switch on would close, whatever it is now. async fn would_close(server: &Server, policy: &Policy) -> trc::Result> { let mut hypothetical = policy.clone(); @@ -164,17 +207,22 @@ pub async fn get( } else { Vec::new() }; + let recent = if properties.contains(&P::RecentLegacyUse) { + server.recent_legacy_use(None).await? + } else { + Vec::new() + }; match ids { None => response .list - .push(to_value(&policy, &would_close, &properties)), + .push(to_value(&policy, &would_close, &recent, &properties)), Some(ids) => { for id in ids { if id.is_singleton() { response .list - .push(to_value(&policy, &would_close, &properties)); + .push(to_value(&policy, &would_close, &recent, &properties)); } else { response.push_not_found(id); } diff --git a/crates/jmap/src/inbuxa/tenant_protocol_policy.rs b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs index 840e817..8b9c95f 100644 --- a/crates/jmap/src/inbuxa/tenant_protocol_policy.rs +++ b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs @@ -17,7 +17,8 @@ //! A tenant's switch closes no port (LP-13) -- sign-in and client //! configuration read it (LP-10, LP-14a). -use common::{Server, auth::AccessToken}; +use crate::inbuxa::protocol_policy::recent_value; +use common::{Server, auth::AccessToken, network::legacy::RecentUse}; use inbuxa_features::{ security::{ protocol_policy::LegacyProtocols, @@ -47,6 +48,7 @@ const ALL: &[P] = &[ P::LegacyProtocols, P::ChangedAt, P::ChangedBy, + P::RecentLegacyUse, ]; /// The tenants this principal may reach: its own inside a tenant (MT-1), @@ -58,7 +60,7 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result PValue { +fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue { let mut out = Map::with_capacity(properties.len()); for property in properties { let value = match property { @@ -81,6 +83,9 @@ fn to_value(tenant_id: u32, policy: &Policy, properties: &[P]) -> PValue { .as_ref() .map(|by| Value::Str(by.clone().into())) .unwrap_or(Value::Null), + P::RecentLegacyUse => { + recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id))) + } }; out.insert_unchecked(Key::Property(property.clone()), value); } @@ -111,9 +116,15 @@ pub async fn get( let tenant_id = id.document_id(); if reachable.contains(&tenant_id) { let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?; + // The tenant's own people only (LP-15, MT-1). + let recent = if properties.contains(&P::RecentLegacyUse) { + server.recent_legacy_use(Some(tenant_id)).await? + } else { + Vec::new() + }; response .list - .push(to_value(tenant_id, &policy, &properties)); + .push(to_value(tenant_id, &policy, &recent, &properties)); } else { response.push_not_found(id); } diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 3d7263d..b2f71ad 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -101,9 +101,10 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?; - // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + // inbuxa: legacy-protocols LP-10 for a bearer token that named no + // account, and LP-15: the sign-in is recorded for the impact panel self.server - .refuse_legacy_session(LegacyProtocol::ManageSieve, &access_token) + .admit_legacy_session(LegacyProtocol::ManageSieve, &access_token) .await?; // Enforce concurrency limits diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index 1c885e3..5a726ad 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -99,9 +99,10 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?; - // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + // inbuxa: legacy-protocols LP-10 for a bearer token that named no + // account, and LP-15: the sign-in is recorded for the impact panel self.server - .refuse_legacy_session(LegacyProtocol::Pop3, &access_token) + .admit_legacy_session(LegacyProtocol::Pop3, &access_token) .await?; // Enforce concurrency limits diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index aa68fd8..d2dc494 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -136,11 +136,12 @@ impl Session { .and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend)); // inbuxa: legacy-protocols LP-10, for a bearer token that named no - // account and so couldn't be judged by its domain beforehand. + // account and so couldn't be judged by its domain beforehand; and + // LP-15, the sign-in is recorded for the impact panel. if let Ok(access_token) = &result && let Err(err) = self .server - .refuse_legacy_session(LegacyProtocol::Submission, access_token) + .admit_legacy_session(LegacyProtocol::Submission, access_token) .await { return self.legacy_refusal(err).await; diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 00a8c01..27fd509 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -30,7 +30,9 @@ leaves every other domain alone, and stops client configuration offering legacy servers for those domains. It reaches only its own tenant's switch, and can't turn it back on while the server has legacy protocols off (acceptance tests 6 to 10, 14). Throughout, the JMAP session tells each -account which way its switches point (test 13). +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). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -292,6 +294,14 @@ def tenant_checks(admin, admin_pw, account): check(session_flag(tu, user_pw) == "enabled", "the session says enabled for the tenant's user while both switches are on (test 13)") + imap_login(PORTS["imap"], tu, user_pw) + got = tget() + recent = got[1]["list"][0].get("recentLegacyUse", []) + names = {(r["name"], r["protocol"]) for r in recent} + check((tu, "imap") in names and not any(n == admin for n, _ in names), + "the tenant's panel lists its own user's IMAP sign-in and nobody outside it (LP-15, MT-1)") + if (tu, "imap") not in names: + print(" recent:", recent) # Before: the tenant's user signs in, and its domain is offered IMAP. check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), @@ -460,6 +470,22 @@ def main(): check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"), "submission sign-in works with the switch on") + # The impact panel (LP-15): the sign-ins above are on it, once each. + got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", + {"accountId": account, "ids": None, "properties": ["recentLegacyUse"]}) + recent = got[1]["list"][0].get("recentLegacyUse", []) + mine = {r["protocol"]: r for r in recent if r["name"] == admin} + check(set(mine) == {"imap", "submission"} and all(r["lastUsedAt"] > 0 for r in mine.values()), + "the panel lists the admin's IMAP and submission sign-ins, and when (LP-15)") + if set(mine) != {"imap", "submission"}: + print(" recent:", recent) + imap_login(PORTS["imap"], admin, admin_pw) + got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", + {"accountId": account, "ids": None, "properties": ["recentLegacyUse"]}) + again = {r["protocol"]: r for r in got[1]["list"][0].get("recentLegacyUse", []) if r["name"] == admin} + check(again.get("imap", {}).get("lastUsedAt") == mine.get("imap", {}).get("lastUsedAt"), + "a second sign-in within the hour isn't written again (LP-15)") + # What the screen reads: the locked set and what would close (LP-16, LP-21). got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get) if got[0] != "inbuxa:ProtocolPolicy/get": From 6c6fe91d0cb41446914451a299b86f431fc472c0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 14:56:04 -0700 Subject: [PATCH 6/6] A deleted tenant's legacy protocols switch goes with it Deleting a tenant now also removes its stored inbuxa:TenantProtocolPolicy, in the same place the registry's other per-type clean-ups run. Without it the row outlived the tenant, and a tenant that later came to have the same id would have started with legacy protocols off. The e2e deletes a tenant whose switch a server administrator had turned off, and would check that a new tenant with the same id starts with them on. On this build the registry hands out a fresh id instead ("d" after "c"), so the reuse -- and with it the removal -- isn't observable over JMAP; the test says so rather than passing silently. The risk it guards was therefore smaller than feared, and the change is mostly about not leaving an orphaned row behind. All 72 checks pass. --- .../src/security/tenant_protocol_policy.rs | 11 +++++++++++ crates/jmap/src/registry/set.rs | 8 ++++++++ tests/e2e/legacy_protocols.py | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/crates/features/src/security/tenant_protocol_policy.rs b/crates/features/src/security/tenant_protocol_policy.rs index 4bdd93d..81e72d9 100644 --- a/crates/features/src/security/tenant_protocol_policy.rs +++ b/crates/features/src/security/tenant_protocol_policy.rs @@ -93,6 +93,17 @@ pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) -> .map(|_| ()) } +/// Forgets a tenant's switch, when the tenant is deleted. Otherwise a tenant +/// that came to have the same id would start with the old one's switch. +pub async fn remove(data: &Store, tenant_id: u32) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.clear(key(tenant_id)); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index a7f59ab..d7a1634 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -851,6 +851,14 @@ impl RegistrySet for Server { if let ObjectInner::MaskedEmail(mask) = &object.inner { crate::inbuxa::masked_email::destroyed(self, id, mask).await?; } + // inbuxa: legacy-protocols, a tenant's switch goes with it + if matches!(object.inner, ObjectInner::Tenant(_)) { + inbuxa_features::security::tenant_protocol_policy::remove( + &self.core.storage.data, + id.document_id(), + ) + .await?; + } cache_invalidator.process_delete(id, &object); set.response.destroyed.push(id); } diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 27fd509..a27706f 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -380,6 +380,24 @@ 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)") + # 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", + {"accountId": account, "ids": ids}) + one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/set", + {"accountId": account, "update": {t2: {"legacyProtocols": "disabled"}}}) + check(sget([t2])[1]["list"][0]["legacyProtocols"] == "disabled", + "a server admin turns another tenant's switch off") + res = one(admin, admin_pw, "x:Tenant/set", {"destroy": [t2]}) + check(t2 in (res[1].get("destroyed") or []), "that tenant can be deleted") + t3 = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t3"}}}), + "t", "third tenant") + if t3 == t2: + check(sget([t3])[1]["list"][0]["legacyProtocols"] == "enabled", + "a new tenant with the deleted one's id starts with legacy protocols on") + else: + print(f" (the registry gave the new tenant a fresh id, {t3} not {t2}: reuse not observable)") + def session_flag(user, password): """legacyProtocols from the account's urn:inbuxa:jmap capability."""