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..ab3e432 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,15 @@ 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, 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; + } 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..c863064 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, LP-14a + let legacy_off = self.legacy_protocols_off_for(domain).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..f9eb2e7 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::{ @@ -34,6 +40,8 @@ impl Server { let network = &self.core.network; let default_host = network.server_name.as_str(); let domain_name = domain.name.as_str(); + // inbuxa: legacy-protocols LP-7, LP-14a + let legacy_off = self.legacy_protocols_off_for(domain_name).await?; let domain_name_suffix = format!(".{domain_name}"); for record_type in record_types { @@ -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, 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 + }; 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..54dcb50 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -21,16 +21,24 @@ //! 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 -- 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::{ + legacy_use::{self, LegacyUse}, listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, + tenant_protocol_policy, }; +use registry::schema::enums::ServiceProtocol; use registry::types::{error::Error, id::ObjectId}; use store::registry::bootstrap::Bootstrap; @@ -102,6 +110,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) } @@ -215,6 +254,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 { @@ -236,39 +281,92 @@ 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 { + /// The same protocol, as the impact panel's record names it (LP-15). + pub fn as_use(&self) -> LegacyUse { match self { - LegacyProtocol::Imap => { + 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 + /// 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) } } +/// 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 { + 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 { @@ -283,21 +381,144 @@ 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(()) + } + + /// 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, + ) -> trc::Result<()> { + if let Some(tenant_id) = access_token.tenant_id() + && self.tenant_legacy_protocols_off(tenant_id).await? + { + return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None)); + } + if let 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 + /// 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( + tenant_protocol_policy::get(&self.core.storage.data, tenant_id) + .await? + .legacy_protocols + .is_disabled(), + ) + } +} + +/// 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 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), } } } @@ -317,28 +538,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. @@ -349,6 +599,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/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 bb06e86..fb3e583 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -10,5 +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/features/src/security/tenant_protocol_policy.rs b/crates/features/src/security/tenant_protocol_policy.rs new file mode 100644 index 0000000..81e72d9 --- /dev/null +++ b/crates/features/src/security/tenant_protocol_policy.rs @@ -0,0 +1,168 @@ +/* + * 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(|_| ()) +} + +/// 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::*; + + 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..5391920 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -100,6 +100,13 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; + // 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 + .admit_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_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 new file mode 100644 index 0000000..a6e0123 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs @@ -0,0 +1,186 @@ +/* + * 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, + /// 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)] +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", + TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse", + } + .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, + b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse, + ) + } +} + +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 + | TenantProtocolPolicyProperty::RecentLegacyUse + ) + } +} + +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/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-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/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/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/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/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/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 new file mode 100644 index 0000000..8b9c95f --- /dev/null +++ b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs @@ -0,0 +1,235 @@ +/* + * 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 crate::inbuxa::protocol_policy::recent_value; +use common::{Server, auth::AccessToken, network::legacy::RecentUse}; +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, + P::RecentLegacyUse, +]; + +/// 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, recent: &[RecentUse], 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), + P::RecentLegacyUse => { + recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id))) + } + }; + 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?; + // 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, &recent, &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/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/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 635443f..b2f71ad 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -101,6 +101,12 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?; + // 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 + .admit_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..5a726ad 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -99,6 +99,12 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?; + // 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 + .admit_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..d2dc494 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,18 @@ 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; and + // LP-15, the sign-in is recorded for the impact panel. + if let Ok(access_token) = &result + && let Err(err) = self + .server + .admit_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 +210,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/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/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/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 1e85d01..3806d5d 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index af97834..480c5d9 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY \ No newline at end of file +rLRbZKj15KvcPMVmnisfCDsXXZEKks6BXpMkMpS0mlI \ No newline at end of file diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 7831291..a27706f 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -17,7 +17,22 @@ 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). +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). Throughout, the JMAP session tells each +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. @@ -189,6 +204,212 @@ 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 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 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}}}) + + 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"), + "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)") + + 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.") + 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"}}}) + 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" + 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") + 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.""" + 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): + 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): @@ -232,6 +453,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} @@ -243,12 +473,37 @@ 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") 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": @@ -295,6 +550,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": { @@ -322,6 +590,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() @@ -340,6 +627,17 @@ 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"], + "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"),