diff --git a/crates/common/src/network/autoconfig/autodiscover.rs b/crates/common/src/network/autoconfig/autodiscover.rs index 5818ba8..ab3e432 100644 --- a/crates/common/src/network/autoconfig/autodiscover.rs +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -57,8 +57,11 @@ impl Server { let _ = writeln!(&mut config, "\t\t"); let _ = writeln!(&mut config, "\t\t\temail"); let _ = writeln!(&mut config, "\t\t\tsettings"); - // inbuxa: legacy-protocols LP-7 - let legacy_off = self.legacy_protocols_off().await?; + // inbuxa: legacy-protocols LP-7, LP-14a + let legacy_off = match emailaddress.rsplit_once('@') { + Some((_, domain)) => self.legacy_protocols_off_for(domain).await?, + None => self.legacy_protocols_off_for("").await?, + }; for (protocol, service) in &self.core.network.info.services { if legacy_off && is_legacy_service(protocol) { continue; diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs index 3bb9c0f..c863064 100644 --- a/crates/common/src/network/autoconfig/legacy_autoconfig.rs +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -30,8 +30,8 @@ impl Server { ("%EMAILADDRESS%", default_host.as_str()) }; - // inbuxa: legacy-protocols LP-7 - let legacy_off = self.legacy_protocols_off().await?; + // inbuxa: legacy-protocols LP-7, LP-14a + let legacy_off = self.legacy_protocols_off_for(domain).await?; // Build XML response let mut config = String::with_capacity(1024); diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index f371522..f9eb2e7 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -39,9 +39,9 @@ impl Server { let mut records = Vec::new(); let network = &self.core.network; let default_host = network.server_name.as_str(); - // inbuxa: legacy-protocols LP-7 - let legacy_off = self.legacy_protocols_off().await?; let domain_name = domain.name.as_str(); + // inbuxa: legacy-protocols LP-7, LP-14a + let legacy_off = self.legacy_protocols_off_for(domain_name).await?; let domain_name_suffix = format!(".{domain_name}"); for record_type in record_types { @@ -417,8 +417,8 @@ impl Server { } pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result { - // inbuxa: legacy-protocols LP-7 - let pacc = if self.legacy_protocols_off().await? { + // inbuxa: legacy-protocols LP-7, LP-14a + let pacc = if self.legacy_protocols_off_for(domain_name).await? { &self.core.network.info.pacc_jmap_only } else { &self.core.network.info.pacc diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index 8952de1..d3e17b8 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -23,17 +23,19 @@ //! //! And nothing advertises what is closed (LP-7): client configuration and //! the suggested DNS records leave the legacy services out, or mark them as -//! not offered, while the switch is off. +//! not offered, while the switch is off -- the server's, or for a tenant's +//! domains, the tenant's (LP-14a). //! //! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! (LP-20). The server stops answering; what still routes the port is the //! operator's to reconcile. -use crate::{Server, config::server::Listeners, network::TcpAcceptor}; +use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor}; use directory::Credentials; use inbuxa_features::security::{ listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, + tenant_protocol_policy, }; use registry::schema::enums::ServiceProtocol; use registry::types::{error::Error, id::ObjectId}; @@ -278,39 +280,71 @@ impl LegacyProtocol { } } - /// What the mail app is told, at server scope (LP-12, LP-6). Each - /// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting — - /// is added by its session; POP3 carries `[AUTH]` in the text, since its - /// errors have no separate code, and SMTP is the whole reply line. - pub fn refusal(&self) -> &'static str { - match self { - LegacyProtocol::Imap => { + /// What the mail app is told (LP-12). Each protocol's own framing -- + /// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session; + /// POP3 carries `[AUTH]` in the text, since its errors have no separate + /// code, and SMTP is the whole reply line. At server scope "Your + /// organization" reads "This server" (LP-6). + pub fn refusal(&self, scope: RefusalScope) -> &'static str { + match (scope, self) { + (RefusalScope::Server, LegacyProtocol::Imap) => { "This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." } - LegacyProtocol::Pop3 => { + (RefusalScope::Server, LegacyProtocol::Pop3) => { "[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." } - LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.", - LegacyProtocol::Submission => { + (RefusalScope::Server, LegacyProtocol::ManageSieve) => { + "This server allows only INBUXA webmail and JMAP apps." + } + (RefusalScope::Server, LegacyProtocol::Submission) => { "535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" } + (RefusalScope::Tenant(_), LegacyProtocol::Imap) => { + "Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + } + (RefusalScope::Tenant(_), LegacyProtocol::Pop3) => { + "[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + } + (RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => { + "Your organization allows only INBUXA webmail and JMAP apps." + } + (RefusalScope::Tenant(_), LegacyProtocol::Submission) => { + "535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" + } } } /// The refusal as an error: `auth.legacy-protocol-refused`, not /// `auth.failed`, so it never counts against the account or feeds the - /// auto-ban (LP-11). It names the protocol and the domain, never the - /// account; the session it is raised in adds the remote IP. - pub fn refused(&self, credentials: &Credentials) -> trc::Error { + /// auto-ban (LP-11). It names the protocol, the scope and the domain, + /// never the account; the session adds the remote IP. + /// + /// Not the tenant's id: `Id` is what IMAP answers a command's tag from, + /// so an error carrying one is sent under the wrong tag and the mail app + /// waits for a reply that never comes. The domain names the tenant. + pub fn refused(&self, scope: RefusalScope, domain: Option) -> trc::Error { trc::AuthEvent::LegacyProtocolRefused .into_err() - .details(self.refusal()) + .details(self.refusal(scope)) .ctx(trc::Key::Source, self.as_str()) - .ctx(trc::Key::Policy, "server") - .ctx_opt(trc::Key::Domain, domain_of(credentials)) + .ctx( + trc::Key::Policy, + match scope { + RefusalScope::Server => "server", + RefusalScope::Tenant(_) => "tenant", + }, + ) + .ctx_opt(trc::Key::Domain, domain) } } +/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefusalScope { + Server, + Tenant(u32), +} + /// The domain a sign-in is for, from the name it gives, if it gives one. fn domain_of(credentials: &Credentials) -> Option { let username = match credentials { @@ -325,22 +359,58 @@ fn domain_of(credentials: &Credentials) -> Option { impl Server { /// Refuses a sign-in over a legacy protocol while the server-wide switch - /// is off (LP-6). Called before the credentials are checked, so the - /// answer is the same for a right password, a wrong one and an account - /// that doesn't exist (LP-11). + /// is off (LP-6), or while the switch of the tenant that owns the named + /// domain is (LP-10). Called before the credentials are checked, so the + /// answer is the same for a right password, a wrong one and an address + /// that doesn't exist (LP-11): a tenant's domain answers for every address + /// on it. /// /// Read from the store on each sign-in rather than cached, so every node - /// of a cluster answers the same the moment the switch turns. + /// of a cluster answers the same the moment a switch turns. pub async fn refuse_legacy_sign_in( &self, protocol: LegacyProtocol, credentials: &Credentials, ) -> trc::Result<()> { + let domain = domain_of(credentials); if self.protocol_policy().await?.legacy_protocols.is_disabled() { - Err(protocol.refused(credentials)) - } else { - Ok(()) + return Err(protocol.refused(RefusalScope::Server, domain)); } + if let Some(name) = &domain + && let Some(domain) = self.domain(name).await? + && let Some(tenant_id) = domain.id_tenant + && self.tenant_legacy_protocols_off(tenant_id).await? + { + return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone()))); + } + Ok(()) + } + + /// The same, once the account is known (LP-10). A bearer token needn't + /// name an account, so a sign-in with one can't be judged by its domain + /// beforehand; this judges it by the tenant the token turned out to + /// belong to. For a password sign-in it has already been decided. + pub async fn refuse_legacy_session( + &self, + protocol: LegacyProtocol, + access_token: &AccessToken, + ) -> trc::Result<()> { + if let Some(tenant_id) = access_token.tenant_id() + && self.tenant_legacy_protocols_off(tenant_id).await? + { + return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None)); + } + Ok(()) + } + + /// Whether a tenant has turned legacy protocols off for itself (LP-10). + pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result { + Ok( + tenant_protocol_policy::get(&self.core.storage.data, tenant_id) + .await? + .legacy_protocols + .is_disabled(), + ) } } @@ -358,10 +428,21 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool { } impl Server { - /// Whether the server-wide switch is off, for the answers that must stop - /// offering legacy services (LP-7). Read per answer, as sign-in reads it. - pub async fn legacy_protocols_off(&self) -> trc::Result { - Ok(self.protocol_policy().await?.legacy_protocols.is_disabled()) + /// Whether legacy services are off for this domain, for the answers that + /// must stop offering them: off for the whole server (LP-7), or for the + /// tenant the domain belongs to (LP-14a). Read per answer, as sign-in + /// reads it. A name that is no domain here answers for the server alone. + pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result { + if self.protocol_policy().await?.legacy_protocols.is_disabled() { + return Ok(true); + } + match self.domain(domain_name).await? { + Some(domain) => match domain.id_tenant { + Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, + None => Ok(false), + }, + None => Ok(false), + } } } @@ -380,28 +461,57 @@ mod tests { #[test] fn refusals_read_as_the_spec_writes_them() { // LP-12, with "Your organization" read as "This server" (LP-6). + let server = RefusalScope::Server; assert_eq!( - LegacyProtocol::Imap.refusal(), + LegacyProtocol::Imap.refusal(server), "This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." ); assert!( LegacyProtocol::Pop3 - .refusal() + .refusal(server) .starts_with("[AUTH] This server allows") ); assert_eq!( - LegacyProtocol::ManageSieve.refusal(), + LegacyProtocol::ManageSieve.refusal(server), "This server allows only INBUXA webmail and JMAP apps." ); assert_eq!( - LegacyProtocol::Submission.refusal(), + LegacyProtocol::Submission.refusal(server), "535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" ); } + #[test] + fn a_tenant_refusal_speaks_for_the_organization() { + // LP-12, exactly as the spec writes them. + let tenant = RefusalScope::Tenant(7); + assert_eq!( + LegacyProtocol::Imap.refusal(tenant), + "Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + ); + assert_eq!( + LegacyProtocol::Pop3.refusal(tenant), + "[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + ); + assert_eq!( + LegacyProtocol::ManageSieve.refusal(tenant), + "Your organization allows only INBUXA webmail and JMAP apps." + ); + assert_eq!( + LegacyProtocol::Submission.refusal(tenant), + "535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" + ); + let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into())); + assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant")); + // IMAP answers the command's tag from Id; the refusal must leave it be. + assert!(err.value(trc::Key::Id).is_none()); + assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused))); + } + #[test] fn a_refusal_is_not_a_failed_sign_in() { - let err = LegacyProtocol::Imap.refused(&basic("maria@Example.org")); + let err = LegacyProtocol::Imap + .refused(RefusalScope::Server, domain_of(&basic("maria@Example.org"))); assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused))); assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed))); // The session stays open: the mail app is told, not thrown off. diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index bb06e86..9d69c95 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -12,3 +12,4 @@ pub mod listeners; pub mod protocol_policy; +pub mod tenant_protocol_policy; diff --git a/crates/features/src/security/tenant_protocol_policy.rs b/crates/features/src/security/tenant_protocol_policy.rs new file mode 100644 index 0000000..4bdd93d --- /dev/null +++ b/crates/features/src/security/tenant_protocol_policy.rs @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:TenantProtocolPolicy`, one tenant's legacy mail protocols switch +//! (legacy-protocols spec, LP-9 to LP-14a). Stored as JSON under `P` `t` and +//! the tenant id in the fork's subspace; a tenant with nothing stored has +//! legacy protocols on. +//! +//! A tenant's switch closes no port -- other tenants share them (LP-13). It +//! refuses sign-in on the tenant's domains, and keeps client configuration +//! for them from offering what's refused. That is all it is: one fact per +//! tenant, easy to turn back, touching no listener, role or permission. + +use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy}; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +/// One tenant's switch. +#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TenantProtocolPolicy { + /// The switch itself. + pub legacy_protocols: LegacyProtocols, + /// When it last changed, in milliseconds since the epoch. + pub changed_at: Option, + /// The account that last changed it. + pub changed_by: Option, +} + +/// Why a tenant's switch can't be set this way, if it can't (LP-9). +/// +/// A tenant can always turn legacy protocols off for itself. It can turn +/// them back on only while the server has them on: server off means off for +/// everyone. +pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> { + (server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some( + "Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \ + so they can't be turned back on for one organization.", + ) +} + +fn key(tenant_id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.extend_from_slice(b"Pt"); + key.extend_from_slice(&tenant_id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +struct Json(TenantProtocolPolicy); + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +/// The tenant's policy, or the default (on) when it has never been set. +pub async fn get(data: &Store, tenant_id: u32) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key(tenant_id))) + .await + .caused_by(trc::location!())? + .map(|Json(policy)| policy) + .unwrap_or_default()) +} + +/// Stores the tenant's policy. +pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) -> trc::Result<()> { + let bytes = serde_json::to_vec(policy).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?; + let mut batch = BatchBuilder::new(); + batch.set(key(tenant_id), bytes); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(legacy_protocols: LegacyProtocols) -> ProtocolPolicy { + ProtocolPolicy { + legacy_protocols, + ..Default::default() + } + } + + #[test] + fn a_tenant_starts_with_legacy_protocols_on() { + assert!( + !TenantProtocolPolicy::default() + .legacy_protocols + .is_disabled() + ); + } + + #[test] + fn a_tenant_can_always_turn_them_off() { + for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] { + assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None); + } + } + + #[test] + fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() { + // LP-9, acceptance test 9. + assert_eq!( + refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled), + None + ); + let why = + refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused"); + assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}"); + } + + #[test] + fn keys_are_per_tenant_and_clear_of_the_server_policy() { + let ValueClass::Any(a) = key(1) else { panic!() }; + let ValueClass::Any(b) = key(2) else { panic!() }; + assert_ne!(a.key, b.key); + assert_eq!(&a.key[..2], b"Pt"); + assert_ne!(a.key, b"Pp".to_vec()); + } + + #[test] + fn stored_json_reads_back() { + let policy = TenantProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + changed_at: Some(1), + changed_by: Some("b".into()), + }; + let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap(); + assert_eq!(back, policy); + // Unknown and missing fields read as defaults. + let Json(back) = Json::deserialize(br#"{"futureField":1}"#).unwrap(); + assert_eq!(back, TenantProtocolPolicy::default()); + } +} diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index e68f9d1..d2fbad7 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -100,6 +100,12 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; + // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + self.server + .refuse_legacy_session(LegacyProtocol::Imap, &access_token) + .await + .map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?; + // Enforce concurrency limits let in_flight = match access_token.is_imap_request_allowed() { LimiterResult::Allowed(in_flight) => Some(in_flight), diff --git a/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs new file mode 100644 index 0000000..10d6fe9 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_tenant_protocol_policy.rs @@ -0,0 +1,180 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:TenantProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: one +//! tenant's legacy mail protocols switch (legacy-protocols spec, LP-9 to +//! LP-14). One per tenant; its id is the tenant's id. +//! +//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client +//! that sets them is answered with `invalidProperties`. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct TenantProtocolPolicy; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TenantProtocolPolicyProperty { + Id, + /// Server-set: the tenant this is the switch of. + TenantId, + /// The switch: `enabled` or `disabled`. + LegacyProtocols, + ChangedAt, + ChangedBy, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TenantProtocolPolicyValue { + Id(Id), +} + +impl Property for TenantProtocolPolicyProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + TenantProtocolPolicyProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + TenantProtocolPolicyProperty::Id => "id", + TenantProtocolPolicyProperty::TenantId => "tenantId", + TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", + TenantProtocolPolicyProperty::ChangedAt => "changedAt", + TenantProtocolPolicyProperty::ChangedBy => "changedBy", + } + .into() + } +} + +impl TenantProtocolPolicyProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => TenantProtocolPolicyProperty::Id, + b"tenantId" => TenantProtocolPolicyProperty::TenantId, + b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols, + b"changedAt" => TenantProtocolPolicyProperty::ChangedAt, + b"changedBy" => TenantProtocolPolicyProperty::ChangedBy, + ) + } +} + +impl TenantProtocolPolicyProperty { + /// Whether this property is the server's to say. A client that sets one + /// is answered with `invalidProperties`. + pub fn is_server_set(&self) -> bool { + matches!( + self, + TenantProtocolPolicyProperty::TenantId + | TenantProtocolPolicyProperty::ChangedAt + | TenantProtocolPolicyProperty::ChangedBy + ) + } +} + +impl FromStr for TenantProtocolPolicyProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + TenantProtocolPolicyProperty::parse(s).ok_or(()) + } +} + +impl Element for TenantProtocolPolicyValue { + type Property = TenantProtocolPolicyProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(TenantProtocolPolicyProperty::Id) => { + Id::from_str(value).ok().map(TenantProtocolPolicyValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + TenantProtocolPolicyValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for TenantProtocolPolicy { + type Property = TenantProtocolPolicyProperty; + + type Element = TenantProtocolPolicyValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = TenantProtocolPolicyProperty::Id; +} + +impl From for TenantProtocolPolicyValue { + fn from(id: Id) -> Self { + TenantProtocolPolicyValue::Id(id) + } +} + +impl JmapObjectId for TenantProtocolPolicyValue { + fn as_id(&self) -> Option { + match self { + TenantProtocolPolicyValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + TenantProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = TenantProtocolPolicyValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for TenantProtocolPolicyProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index b61ac6f..50d17b6 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -23,6 +23,7 @@ pub mod email_submission; pub mod fastmail_masked_email; // inbuxa: masked email pub mod inbuxa_ai_limits; // inbuxa: AI spam classification pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off +pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod file_node; pub mod identity; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 860525b..7a62d74 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -64,6 +64,9 @@ impl Response<'_> { GetResponseMethod::ProtocolPolicy(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::TenantProtocolPolicy(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 78d6ce3..84d1f27 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -47,6 +47,9 @@ impl Response<'_> { GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?, + GetRequestMethod::TenantProtocolPolicy(request) => { + request.resolve_references(self)? + } GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -93,6 +96,9 @@ impl Response<'_> { SetRequestMethod::ProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::TenantProtocolPolicy(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 42c0a10..a6baff3 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -50,6 +50,7 @@ pub enum MethodObject { // inbuxa: AI call limits AiLimits, ProtocolPolicy, + TenantProtocolPolicy, } impl MethodObject { @@ -77,6 +78,7 @@ impl MethodObject { MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, + MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } } } @@ -256,6 +258,12 @@ impl MethodName { (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get", (MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set", + (MethodFunction::Get, MethodObject::TenantProtocolPolicy) => { + "inbuxa:TenantProtocolPolicy/get" + } + (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => { + "inbuxa:TenantProtocolPolicy/set" + } (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -383,6 +391,8 @@ impl MethodName { "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), + "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), + "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -437,6 +447,7 @@ impl Display for MethodObject { MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", + MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index cdca15d..dfb7bfe 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -117,6 +117,9 @@ pub enum GetRequestMethod { DeletedAccount(Box>), AiLimits(Box>), ProtocolPolicy(Box>), + TenantProtocolPolicy( + Box>, + ), } #[derive(Debug)] @@ -141,6 +144,9 @@ pub enum SetRequestMethod<'x> { DeletedAccount(Box>), AiLimits(Box>), ProtocolPolicy(Box>), + TenantProtocolPolicy( + Box>, + ), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 381eace..3ad9d05 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -176,6 +176,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::TenantProtocolPolicy) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Get(GetRequestMethod::TenantProtocolPolicy(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -348,6 +357,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Set(SetRequestMethod::TenantProtocolPolicy(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 9ab4de8..5caca70 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -104,6 +104,9 @@ pub enum GetResponseMethod { DeletedAccount(GetResponse), AiLimits(GetResponse), ProtocolPolicy(GetResponse), + TenantProtocolPolicy( + GetResponse, + ), } #[derive(Debug, serde::Serialize)] @@ -129,6 +132,9 @@ pub enum SetResponseMethod { DeletedAccount(Box>), AiLimits(Box>), ProtocolPolicy(Box>), + TenantProtocolPolicy( + Box>, + ), } #[derive(Debug, serde::Serialize)] @@ -305,6 +311,26 @@ impl<'x> From } } +impl<'x> From> + for ResponseMethod<'x> +{ + fn from( + value: GetResponse, + ) -> Self { + ResponseMethod::Get(GetResponseMethod::TenantProtocolPolicy(value)) + } +} + +impl<'x> From> + for ResponseMethod<'x> +{ + fn from( + value: SetResponse, + ) -> Self { + ResponseMethod::Set(SetResponseMethod::TenantProtocolPolicy(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::AiLimits(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index baa73fa..0154cf8 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -80,6 +80,10 @@ impl JmapAuthorization for AccessToken { // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, + // inbuxa: legacy protocols off, per tenant. It governs + // sign-in on the tenant's domains, so it takes the domain's + // permissions, which a tenant administrator already holds. + GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -184,6 +188,14 @@ impl JmapAuthorization for AccessToken { Permission::SysNetworkListenerUpdate, Permission::SysNetworkListenerUpdate, ), + // inbuxa: legacy protocols off, per tenant, with the domain's + SetRequestMethod::TenantProtocolPolicy(s) => validate_set( + s, + self, + Permission::SysDomainUpdate, + Permission::SysDomainUpdate, + Permission::SysDomainUpdate, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -294,7 +306,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::MaskedEmail | MethodObject::DeletedAccount | MethodObject::AiLimits - | MethodObject::ProtocolPolicy => Permission::JmapEmailChanges, + | MethodObject::ProtocolPolicy + | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index b521727..3a5b3fb 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -224,6 +224,9 @@ impl RequestHandler for Server { SetResponseMethod::ProtocolPolicy(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::TenantProtocolPolicy(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -386,6 +389,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:TenantProtocolPolicy/get (legacy protocols off, per tenant) + GetRequestMethod::TenantProtocolPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::tenant_protocol_policy::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -634,6 +644,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant) + SetRequestMethod::TenantProtocolPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::tenant_protocol_policy::set(self, access_token, *req) + .await? + .into() + } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 9f091ae..c35b3f4 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -419,6 +419,7 @@ impl IntermediateChangesResponse { | MethodObject::DeletedAccount | MethodObject::AiLimits | MethodObject::ProtocolPolicy + | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index a04c2c5..fa78c8e 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -10,6 +10,7 @@ pub mod access; pub mod ai_limits; pub mod protocol_policy; +pub mod tenant_protocol_policy; pub mod deleted_account; pub mod fastmail; pub mod masked_email; diff --git a/crates/jmap/src/inbuxa/tenant_protocol_policy.rs b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs new file mode 100644 index 0000000..840e817 --- /dev/null +++ b/crates/jmap/src/inbuxa/tenant_protocol_policy.rs @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:TenantProtocolPolicy/get` and `/set`: one tenant's legacy mail +//! protocols switch (legacy-protocols spec, LP-9 to LP-14). There is one per +//! tenant, and its id is the tenant's. +//! +//! Inside a tenant, a principal reaches only its own tenant's (MT-1): `/get` +//! with no ids answers with it, and any other id is `notFound`. At server +//! level, `/get` with no ids answers with every tenant's. +//! +//! Turning it off never needs the server's leave; turning it back on is +//! refused with `forbidden` while the server has legacy protocols off (LP-9). +//! A tenant's switch closes no port (LP-13) -- sign-in and client +//! configuration read it (LP-10, LP-14a). + +use common::{Server, auth::AccessToken}; +use inbuxa_features::{ + security::{ + protocol_policy::LegacyProtocols, + tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal}, + }, + tenancy::quota::all_tenants, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_tenant_protocol_policy::{ + TenantProtocolPolicy, TenantProtocolPolicyProperty as P, TenantProtocolPolicyValue, + }, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use types::id::Id; + +type PValue = Value<'static, P, TenantProtocolPolicyValue>; + +const ALL: &[P] = &[ + P::Id, + P::TenantId, + P::LegacyProtocols, + P::ChangedAt, + P::ChangedBy, +]; + +/// The tenants this principal may reach: its own inside a tenant (MT-1), +/// every tenant at server level. +async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result> { + match access_token.tenant_id() { + Some(tenant_id) => Ok(vec![tenant_id]), + None => all_tenants(server.registry()).await, + } +} + +fn to_value(tenant_id: u32, policy: &Policy, properties: &[P]) -> PValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id | P::TenantId => { + Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id))) + } + P::LegacyProtocols => Value::Str( + match policy.legacy_protocols { + LegacyProtocols::Enabled => "enabled", + LegacyProtocols::Disabled => "disabled", + } + .into(), + ), + P::ChangedAt => policy + .changed_at + .map(|at| Value::Number(at.into())) + .unwrap_or(Value::Null), + P::ChangedBy => policy + .changed_by + .as_ref() + .map(|by| Value::Str(by.clone().into())) + .unwrap_or(Value::Null), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:TenantProtocolPolicy/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + + let reachable = reachable(server, access_token).await?; + let wanted = match ids { + None => reachable.iter().map(|id| Id::from(*id)).collect(), + Some(ids) => ids, + }; + for id in wanted { + let tenant_id = id.document_id(); + if reachable.contains(&tenant_id) { + let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?; + response + .list + .push(to_value(tenant_id, &policy, &properties)); + } else { + response.push_not_found(id); + } + } + Ok(response) +} + +/// `inbuxa:TenantProtocolPolicy/set`: turns one tenant's switch. Unset +/// (`null`) puts legacy protocols back on, which LP-9 may refuse. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, TenantProtocolPolicy>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + // A tenant's switch comes and goes with the tenant; it is only turned. + for (client_id, _) in request.unwrap_create() { + response.not_created.append( + client_id, + SetError::forbidden().with_description("A tenant's switch exists with the tenant."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("A tenant's switch exists with the tenant."), + ); + } + + let reachable = reachable(server, access_token).await?; + for (id, value) in request.unwrap_update().into_valid() { + let tenant_id = id.document_id(); + if !reachable.contains(&tenant_id) { + response.not_updated.append(id, SetError::not_found()); + continue; + } + + let data = &server.core.storage.data; + let previous = tenant_protocol_policy::get(data, tenant_id).await?; + let mut policy = previous.clone(); + let mut error = None; + for (key, value) in value.into_expanded_object() { + let result = match &key { + Key::Property(P::LegacyProtocols) => match value { + Value::Null => { + policy.legacy_protocols = LegacyProtocols::Enabled; + Ok(()) + } + value => match value.as_str().as_deref() { + Some("enabled") => { + policy.legacy_protocols = LegacyProtocols::Enabled; + Ok(()) + } + Some("disabled") => { + policy.legacy_protocols = LegacyProtocols::Disabled; + Ok(()) + } + _ => Err(r#"must be "enabled" or "disabled""#), + }, + }, + Key::Property(P::Id) => Err("is immutable"), + Key::Property(_) => Err("is set by the server"), + _ => Err("is not a property of inbuxa:TenantProtocolPolicy"), + }; + if let Err(why) = result { + error = Some( + SetError::invalid_properties() + .with_property(key.into_owned()) + .with_description(why), + ); + break; + } + } + if let Some(error) = error { + response.not_updated.append(id, error); + continue; + } + + // LP-9: server off means off for everyone. + if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) { + response + .not_updated + .append(id, SetError::forbidden().with_description(why)); + continue; + } + + if policy.legacy_protocols != previous.legacy_protocols { + policy.changed_at = Some(store::write::now() * 1000); + policy.changed_by = Some(Id::from(access_token.account_id()).to_string()); + tenant_protocol_policy::set(data, tenant_id, &policy).await?; + + // LP-14. A tenant's switch closes and reopens nothing (LP-13). + trc::event!( + Security(trc::SecurityEvent::LegacyProtocolsChanged), + Policy = "tenant", + Id = tenant_id, + Value = if policy.legacy_protocols.is_disabled() { + "disabled" + } else { + "enabled" + }, + AccountId = policy.changed_by.clone(), + ); + } + response.updated.append(id, None); + } + Ok(response) +} diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 635443f..3d7263d 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -101,6 +101,11 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?; + // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + self.server + .refuse_legacy_session(LegacyProtocol::ManageSieve, &access_token) + .await?; + // Enforce concurrency limits let in_flight = match access_token.is_imap_request_allowed() { LimiterResult::Allowed(in_flight) => Some(in_flight), diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index 1a7c832..1c885e3 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -99,6 +99,11 @@ impl Session { }) .and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?; + // inbuxa: legacy-protocols LP-10, for a bearer token that named no account + self.server + .refuse_legacy_session(LegacyProtocol::Pop3, &access_token) + .await?; + // Enforce concurrency limits let in_flight = match access_token.is_imap_request_allowed() { LimiterResult::Allowed(in_flight) => Some(in_flight), diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index b6c5b3e..aa68fd8 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -121,16 +121,7 @@ impl Session { .refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials) .await { - let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused)); - trc::error!(err.span_id(self.data.session_id)); - if refused { - self.write(LegacyProtocol::Submission.refusal().as_bytes()) - .await?; - } else { - self.write(b"454 4.7.0 Temporary authentication failure\r\n") - .await?; - } - return Ok(false); + return self.legacy_refusal(err).await; } // Authenticate @@ -144,6 +135,17 @@ impl Session { .await .and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend)); + // inbuxa: legacy-protocols LP-10, for a bearer token that named no + // account and so couldn't be judged by its domain beforehand. + if let Ok(access_token) = &result + && let Err(err) = self + .server + .refuse_legacy_session(LegacyProtocol::Submission, access_token) + .await + { + return self.legacy_refusal(err).await; + } + let result = match result { Ok(access_token) => self.server.account_info(access_token.account_id()).await, Err(err) => Err(err), @@ -207,6 +209,26 @@ impl Session { Ok(false) } + /// inbuxa: legacy-protocols LP-6, LP-10. A refusal is written with the + /// words the error carries, which know whose switch refused; anything + /// else that went wrong deciding is a temporary failure. Neither counts + /// as an authentication error (LP-11). + async fn legacy_refusal(&mut self, err: trc::Error) -> Result { + let reply = err + .matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused)) + .then(|| err.value_as_str(trc::Key::Details).map(str::to_string)) + .flatten(); + trc::error!(err.span_id(self.data.session_id)); + match reply { + Some(reply) => self.write(reply.as_bytes()).await?, + None => { + self.write(b"454 4.7.0 Temporary authentication failure\r\n") + .await? + } + } + Ok(false) + } + pub async fn auth_error(&mut self, response: &[u8]) -> Result { tokio::time::sleep(self.params.auth_errors_wait).await; self.data.auth_errors += 1; diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index f74e614..26ab77e 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -23,6 +23,14 @@ and the suggested zone marks their SRV names not offered (LP-7, test 5). Every change of the switch, and every refused sign-in, is an event in the server's log (LP-8, test 14; LP-6). +Then a tenant's own switch (LP-9 to LP-14a): a tenant administrator turns it +off for its tenant, which refuses sign-in on the tenant's domains -- real +address or made-up, right password or wrong -- in the organization's words, +leaves every other domain alone, and stops client configuration offering +legacy servers for those domains. It reaches only its own tenant's switch, +and can't turn it back on while the server has legacy protocols off +(acceptance tests 6 to 10, 14). + Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. """ @@ -234,6 +242,127 @@ def events(name): return [l for l in (out.stdout + out.stderr).splitlines() if f"({name})" in l] +def pop3_login(port, user, password): + """The reply to PASS, over implicit TLS.""" + with tls(port) as sock: + read = lines(sock) + next(read) # greeting + sock.sendall(f"USER {user}\r\n".encode()) + next(read) + sock.sendall(f"PASS {password}\r\n".encode()) + return next(read, "") + + +def created(res, key, what): + obj = (res[1].get("created") or {}).get(key) + if not obj: + sys.exit(f"creating {what} failed: " + json.dumps(res[1])[:600]) + return obj["id"] + + +def tenant_checks(admin, admin_pw, account): + """LP-9 to LP-14a, on a tenant with its own domain, user and admin.""" + t = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t"}}}), + "t", "tenant") + t2 = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t2"}}}), + "t", "second tenant") + domain = created(one(admin, admin_pw, "x:Domain/set", {"create": {"d": { + "name": "t.legacy.test", "isEnabled": True, "memberTenantId": t, + "certificateManagement": {"@type": "Manual"}, "dnsManagement": {"@type": "Manual"}, + "dkimManagement": {"@type": "Manual"}}}}), "d", "tenant domain") + user_pw = secret_file("legacy-tenant-user") + tadmin_pw = secret_file("legacy-tenant-admin") + def user(name, password, extra=None): + body = {"@type": "User", "name": name, "domainId": domain, + "credentials": {"0": {"@type": "Password", "secret": password}}} + body.update(extra or {}) + return created(one(admin, admin_pw, "x:Account/set", {"create": {"a": body}}), + "a", f"account {name}") + user("u", user_pw) + user("tadmin", tadmin_pw, {"roles": {"@type": "Admin"}}) + tu, ta = "u@t.legacy.test", "tadmin@t.legacy.test" + + tsess = session(ta, tadmin_pw) + tacct = tsess["primaryAccounts"].get(INBUXA) or list(tsess["accounts"])[0] + tget = lambda ids=None: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/get", + {"accountId": tacct, "ids": ids}) + tset = lambda value: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", + {"accountId": tacct, "update": {t: {"legacyProtocols": value}}}) + + # Before: the tenant's user signs in, and its domain is offered IMAP. + check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), + "a tenant's user signs in over IMAP with the tenant's switch on") + got = tget() + mine = [p["id"] for p in got[1].get("list", [])] + check(got[0] == "inbuxa:TenantProtocolPolicy/get" and mine == [t], + "a tenant admin's /get answers with its own tenant's switch only (test 10)") + if mine != [t]: + print(" reply:", json.dumps(got)[:400]) + got = tget([t2]) + check(got[1].get("notFound") == [t2], "another tenant's switch is not found (test 10, MT-1)") + res = one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", + {"accountId": tacct, "update": {t2: {"legacyProtocols": "disabled"}}}) + check(t2 in (res[1].get("notUpdated") or {}), "nor can it be changed (test 10)") + + # The tenant admin turns it off for its tenant (LP-9). + res = tset("disabled") + check(t in (res[1].get("updated") or {}), "a tenant admin turns legacy protocols off (LP-9)") + if t not in (res[1].get("updated") or {}): + print(" reply:", json.dumps(res)[:400]) + check(events_matching("security.legacy-protocols-changed", 'policy = "tenant"', + 'value = "disabled"'), + "and it is an event, scope tenant (LP-14, test 14)") + + # Refused on the tenant's domain, every way in the same words (tests 6-8). + imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. " + "This mail app can't sign in.") + check(imap_login(PORTS["imap"], tu, user_pw) == imap_no, + "the tenant's user is refused over IMAP with the right password (test 6)") + check(imap_login(PORTS["imap"], tu, "wrong") == imap_no, "and with a wrong one (test 6)") + check(imap_login(PORTS["imap"], "nobody@t.legacy.test", "x") == imap_no, + "and a made-up address on the domain gets the same (test 7)") + check(pop3_login(PORTS["pop3"], tu, user_pw) == + "-ERR [AUTH] Your organization allows only INBUXA webmail and JMAP apps. " + "This mail app can't sign in.", "POP3 refuses in its own form (test 8)") + check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] == + "535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. " + "This mail app can't send.", "submission refuses in its own form (test 8)") + check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), + "an account on another domain signs in over IMAP normally (test 6)") + check(session(tu, user_pw).get("accounts"), "the tenant's user still has JMAP (test 8)") + check(not events("auth.failed"), "no refusal counted as a failed sign-in (LP-11)") + + # Client configuration for the tenant's domain only (LP-14a). + with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress={tu}", timeout=30) as r: + tenant_cfg = r.read().decode() + with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress={admin}", timeout=30) as r: + other_cfg = r.read().decode() + check('type="imap"' not in tenant_cfg and 'type="imap"' in other_cfg, + "autoconfig offers no IMAP for the tenant's domain, and still does elsewhere (LP-14a)") + + # Server off means off for everyone: the tenant can't turn it back on (test 9). + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": {"legacyProtocols": "disabled"}}}) + res = tset("enabled") + refused = (res[1].get("notUpdated") or {}).get(t) or {} + check(refused.get("type") == "forbidden" + and "inbuxa:ProtocolPolicy" in (refused.get("description") or ""), + "with the server off, the tenant can't turn them back on (LP-9, test 9)") + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", + {"accountId": account, "update": {"singleton": {"legacyProtocols": "enabled"}}}) + check(settle(PORTS["imap"], True), "IMAP is back after the server switch returns") + + # And back on, the tenant's user signs in again. + res = tset("enabled") + check(t in (res[1].get("updated") or {}), "with the server on, the tenant turns them back on") + check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), + "and its user signs in over IMAP again") + + +def events_matching(name, *parts): + return any(all(p in line for p in parts) for line in events(name)) + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -444,6 +573,9 @@ def main(): check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], "autoconfig and the suggested zone offer them again once back on") + # A tenant's own switch (LP-9 to LP-14a). + tenant_checks(admin, admin_pw, account) + # And sign-in works again, with no restart. check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), "IMAP sign-in works again once the switch is back on")