A tenant can turn legacy protocols off for itself (LP-9 to LP-14a)
The tenant switch. A tenant's administrator turns legacy mail protocols off for its own tenant, and from then on sign-in over IMAP, POP3, ManageSieve and SMTP AUTH is refused for every address on the tenant's domains, while every other domain on the server carries on. No port closes, since other tenants share them (LP-13): it is one stored fact per tenant, read at sign-in and when client configuration is answered. inbuxa:TenantProtocolPolicy/get and /set, one per tenant, id the tenant's: - Inside a tenant, a principal reaches only its own tenant's switch (MT-1): /get with no ids answers with it, another tenant's is notFound and can't be changed. At server level /get with no ids lists every tenant's. - Turning it off is always allowed. Turning it back on is refused with forbidden, naming inbuxa:ProtocolPolicy, while the server has legacy protocols off (LP-9). - A change raises security.legacy-protocols-changed with policy = tenant, the tenant's id, the new value and who made it (LP-14). - It takes sysDomainGet and sysDomainUpdate, not the two new permissions the spec names. The switch governs sign-in on the tenant's domains, so whoever manages those domains may turn it -- and the default Tenant Administrator role already holds both, where new permissions would reach no role already stored on a server (MT-12's note), leaving today's tenant administrators without the switch until someone edited their role by hand. The same trade inbuxa:AiLimits and inbuxa:ProtocolPolicy made. /query is not built yet; /get with no ids covers listing. Sign-in (LP-10 to LP-12). Before the credentials are looked at, the name given is resolved to its domain and the domain to its tenant, so a real account and a made-up address on the domain get the same refusal, with a right password or a wrong one, counted as no failed sign-in (LP-11). The words are the spec's: "Your organization allows only INBUXA webmail and JMAP apps...", in each protocol's form. A bearer token needn't name an account, so after authentication the account's own tenant is checked too; a token that named nobody can't slip past. The refusal carries policy = tenant and the domain, not the tenant's id: IMAP answers a command's tag from the Id key, so an error holding one was sent under the wrong tag and the mail app hung waiting for its reply. The first live run found that; a unit test now holds the refusal to it. Client configuration (LP-14a). Autoconfig, autodiscover, PACC and the suggested DNS records now ask whether legacy services are off for the domain being answered for -- the server's switch, or the domain's tenant's -- so a tenant's domains stop offering IMAP, POP3 and submission while others still do. tests/e2e/legacy_protocols.py builds a tenant with its own domain, a user and a tenant administrator, and a second tenant, and proves on a running server: the admin sees and changes only its own tenant's switch (test 10); turning it off is an event (test 14); the tenant's user is refused over IMAP with the right password and a wrong one, a made-up address on the domain the same (tests 6, 7); POP3 and submission refuse in their own forms and JMAP still works (test 8); an account on another domain signs in normally (test 6); autoconfig drops IMAP for the tenant's domain only; with the server off, the tenant can't turn it back on (test 9); and once back on, the user signs in again. All 62 checks pass.
This commit is contained in:
@@ -57,8 +57,11 @@ impl Server {
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
// 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> {
|
||||
// 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
|
||||
|
||||
@@ -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<String>) -> 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<String> {
|
||||
let username = match credentials {
|
||||
@@ -325,22 +359,58 @@ fn domain_of(credentials: &Credentials) -> Option<String> {
|
||||
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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("[email protected]"));
|
||||
let err = LegacyProtocol::Imap
|
||||
.refused(RefusalScope::Server, domain_of(&basic("[email protected]")));
|
||||
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.
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
|
||||
pub mod listeners;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
|
||||
@@ -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<u64>,
|
||||
/// The account that last changed it.
|
||||
pub changed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
|
||||
///
|
||||
/// 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<Self> {
|
||||
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<TenantProtocolPolicy> {
|
||||
Ok(data
|
||||
.get_value::<Json>(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());
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,12 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.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),
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
TenantProtocolPolicyProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TenantProtocolPolicyValue {
|
||||
type Property = TenantProtocolPolicyProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<Id> for TenantProtocolPolicyValue {
|
||||
fn from(id: Id) -> Self {
|
||||
TenantProtocolPolicyValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for TenantProtocolPolicyValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
TenantProtocolPolicyValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -117,6 +117,9 @@ pub enum GetRequestMethod {
|
||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -141,6 +144,9 @@ pub enum SetRequestMethod<'x> {
|
||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -104,6 +104,9 @@ pub enum GetResponseMethod {
|
||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -129,6 +132,9 @@ pub enum SetResponseMethod {
|
||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -305,6 +311,26 @@ impl<'x> From<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||
for ResponseMethod<'x>
|
||||
{
|
||||
fn from(
|
||||
value: GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::TenantProtocolPolicy(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||
for ResponseMethod<'x>
|
||||
{
|
||||
fn from(
|
||||
value: SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::TenantProtocolPolicy(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u32>> {
|
||||
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<TenantProtocolPolicy>,
|
||||
) -> trc::Result<GetResponse<TenantProtocolPolicy>> {
|
||||
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<SetResponse<TenantProtocolPolicy>> {
|
||||
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)
|
||||
}
|
||||
@@ -101,6 +101,11 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.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),
|
||||
|
||||
@@ -99,6 +99,11 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.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),
|
||||
|
||||
@@ -121,16 +121,7 @@ impl<T: SessionStream> Session<T> {
|
||||
.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<T: SessionStream> Session<T> {
|
||||
.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<T: SessionStream> Session<T> {
|
||||
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<bool, ()> {
|
||||
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<bool, ()> {
|
||||
tokio::time::sleep(self.params.auth_errors_wait).await;
|
||||
self.data.auth_errors += 1;
|
||||
|
||||
@@ -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 = "[email protected]", "[email protected]"
|
||||
|
||||
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"], "[email protected]", "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")
|
||||
|
||||
Reference in New Issue
Block a user