Give IMAP, POP3 and ManageSieve a switch each
The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).
legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.
SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.
Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.
Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
This commit is contained in:
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Empty(EmptyCapabilities::default()),
|
||||
);
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
||||
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
|
||||
// per protocol. `legacyProtocols` stays for older webmail builds:
|
||||
// `disabled` only when every protocol is off.
|
||||
let legacy_off = self.legacy_off_for_account(access_token).await?;
|
||||
let legacy_protocols = if legacy_off.all() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
let legacy_allowed = legacy_off.allowed();
|
||||
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
|
||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||
&& access_token.tenant_id().is_none()
|
||||
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
logo,
|
||||
legacy_protocols,
|
||||
legacy_allowed,
|
||||
ai_explain,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -376,7 +376,11 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
|
||||
}
|
||||
|
||||
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
||||
use inbuxa_features::{ai::limits, audit::log, security};
|
||||
use inbuxa_features::{
|
||||
ai::limits,
|
||||
audit::log,
|
||||
security::{self, protocol_policy::Switches},
|
||||
};
|
||||
let data = server.store();
|
||||
match object {
|
||||
"inbuxa:AuditSettings" => log::settings(data)
|
||||
@@ -387,10 +391,17 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|limits| serde_json::to_value(limits).ok()),
|
||||
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
||||
// Normalized, so every switch reads before and after, even from a
|
||||
// policy stored before the per-protocol switches
|
||||
"inbuxa:ProtocolPolicy" => {
|
||||
security::protocol_policy::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut policy| {
|
||||
policy.normalize();
|
||||
serde_json::to_value(policy).ok()
|
||||
})
|
||||
}
|
||||
// LH-1: a hold as the API shows it, so a change reads before/after
|
||||
"inbuxa:LegalHold" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
@@ -424,7 +435,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
security::tenant_protocol_policy::get(data, id.document_id())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok())
|
||||
.and_then(|mut policy| {
|
||||
policy.normalize();
|
||||
serde_json::to_value(policy).ok()
|
||||
})
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => None,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,9 @@ use common::{
|
||||
};
|
||||
use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||
protocol_policy::{
|
||||
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::CloseSubmission,
|
||||
P::SavedListeners,
|
||||
P::ChangedAt,
|
||||
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// A switch as JMAP spells it.
|
||||
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
|
||||
match value {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// A switch from JMAP.
|
||||
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
|
||||
match value {
|
||||
Some("enabled") => Ok(LegacyProtocols::Enabled),
|
||||
Some("disabled") => Ok(LegacyProtocols::Disabled),
|
||||
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The JMAP name of a per-protocol switch property.
|
||||
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
|
||||
match property {
|
||||
P::Imap => Some("imap"),
|
||||
P::Pop3 => Some("pop3"),
|
||||
P::ManageSieve => Some("manageSieve"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(
|
||||
policy: &Policy,
|
||||
would_close: &[SavedListener],
|
||||
recent: &[RecentUse],
|
||||
properties: &[P],
|
||||
) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::CloseSubmission => Value::Bool(policy.close_submission),
|
||||
P::SavedListeners => Value::Array(
|
||||
@@ -176,10 +208,11 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// The listeners turning the switch on would close, whatever it is now.
|
||||
/// The listeners turning every protocol off would close, whatever the switches
|
||||
/// are now; each names its protocol, so the console shows one protocol's.
|
||||
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||
let mut hypothetical = policy.clone();
|
||||
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
|
||||
hypothetical.set_all(LegacyProtocols::Disabled);
|
||||
hypothetical.apply_locks();
|
||||
listeners::would_close(server.registry(), &hypothetical).await
|
||||
}
|
||||
@@ -238,12 +271,12 @@ fn apply(
|
||||
value: &Value<'_, P, ProtocolPolicyValue>,
|
||||
) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => {
|
||||
policy.legacy_protocols = match value.as_str().as_deref() {
|
||||
Some("enabled") => LegacyProtocols::Enabled,
|
||||
Some("disabled") => LegacyProtocols::Disabled,
|
||||
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
// The kill-all sets all three; a protocol named in the same /set is
|
||||
// applied after it (see `set`), so it wins.
|
||||
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
let value = parse_switch(value.as_str().as_deref())?;
|
||||
policy.set(switch_name(property).unwrap_or_default(), value);
|
||||
}
|
||||
P::CloseSubmission => {
|
||||
policy.close_submission = value
|
||||
@@ -262,7 +295,13 @@ fn apply(
|
||||
/// Puts a property back to its default (a `null` in `/set`).
|
||||
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
|
||||
P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
policy.set(
|
||||
switch_name(property).unwrap_or_default(),
|
||||
LegacyProtocols::Enabled,
|
||||
);
|
||||
}
|
||||
P::CloseSubmission => policy.close_submission = defaults.close_submission,
|
||||
P::Id => return Err("is immutable".to_string()),
|
||||
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
||||
@@ -312,10 +351,14 @@ pub async fn set(
|
||||
}
|
||||
|
||||
let mut policy = server.protocol_policy().await?;
|
||||
policy.normalize();
|
||||
let defaults = Policy::default();
|
||||
let mut error = None;
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
// The kill-all first, so a protocol named beside it overrides it.
|
||||
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||
for (key, value) in entries {
|
||||
let Key::Property(property) = &key else {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
|
||||
let protocol = listeners::protocol_name(listener.protocol);
|
||||
// A submission listener closes because of its port, not its protocol
|
||||
// (LP-3), so the port is what would have to change.
|
||||
let property = if protocol == "smtp" {
|
||||
Property::Bind
|
||||
let (property, what) = if protocol == "smtp" {
|
||||
(Property::Bind, "Legacy mail protocols are".to_string())
|
||||
} else {
|
||||
Property::Protocol
|
||||
(Property::Protocol, format!("{} is", display_name(protocol)))
|
||||
};
|
||||
Some((
|
||||
property,
|
||||
format!(
|
||||
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
|
||||
listener would reopen a port the switch keeps closed. Turn legacy protocols \
|
||||
back on first."
|
||||
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
|
||||
a port the switch keeps closed. Turn it back on first."
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// A protocol's name as people read it.
|
||||
fn display_name(protocol: &str) -> &str {
|
||||
match protocol {
|
||||
"imap" => "IMAP",
|
||||
"pop3" => "POP3",
|
||||
"manageSieve" => "ManageSieve",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -461,6 +513,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_protocol_off_refuses_only_its_listeners() {
|
||||
let mut policy = Policy::default();
|
||||
policy.set("pop3", LegacyProtocols::Disabled);
|
||||
policy.normalize();
|
||||
let (property, why) = listener_refusal(
|
||||
&policy,
|
||||
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
|
||||
)
|
||||
.expect("refused");
|
||||
assert_eq!(property, Property::Protocol);
|
||||
assert!(why.starts_with("POP3 is off"), "{why}");
|
||||
assert!(
|
||||
listener_refusal(
|
||||
&policy,
|
||||
&listener(NetworkListenerProtocol::Imap, "[::]:993")
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switch_reads_and_parses_as_jmap_spells_it() {
|
||||
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
|
||||
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
|
||||
assert!(parse_switch(Some("off")).is_err());
|
||||
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
|
||||
assert_eq!(switch_name(&P::CloseSubmission), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_still_allows_what_the_switch_never_closes() {
|
||||
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
|
||||
|
||||
@@ -12,16 +12,22 @@
|
||||
//! with no ids answers with it, and any other id is `notFound`. At server
|
||||
//! level, `/get` with no ids answers with every tenant's.
|
||||
//!
|
||||
//! Turning it off never needs the server's leave; turning it back on is
|
||||
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
||||
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
|
||||
//! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
|
||||
//! off never needs the server's leave; turning one back on is refused with
|
||||
//! `forbidden` while the server has that protocol off (LP-9).
|
||||
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
||||
//! configuration read it (LP-10, LP-14a).
|
||||
|
||||
use crate::inbuxa::protocol_policy::recent_value;
|
||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
|
||||
use common::{
|
||||
Server,
|
||||
auth::AccessToken,
|
||||
network::legacy::{RecentUse, switches_value},
|
||||
};
|
||||
use inbuxa_features::{
|
||||
security::{
|
||||
protocol_policy::LegacyProtocols,
|
||||
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
|
||||
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||
},
|
||||
tenancy::quota::all_tenants,
|
||||
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::TenantId,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::ChangedAt,
|
||||
P::ChangedBy,
|
||||
P::RecentLegacyUse,
|
||||
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The JMAP name of a per-protocol switch property.
|
||||
fn switch_name(property: &P) -> Option<&'static str> {
|
||||
match property {
|
||||
P::Imap => Some("imap"),
|
||||
P::Pop3 => Some("pop3"),
|
||||
P::ManageSieve => Some("manageSieve"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id | P::TenantId => {
|
||||
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||
}
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::ChangedAt => policy
|
||||
.changed_at
|
||||
@@ -165,29 +184,41 @@ pub async fn set(
|
||||
let data = &server.core.storage.data;
|
||||
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||
let mut policy = previous.clone();
|
||||
policy.normalize();
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
// What this request sets to `enabled`, for LP-9.
|
||||
let mut turned_on: Vec<&'static str> = Vec::new();
|
||||
// The kill-all first, so a protocol named beside it overrides it.
|
||||
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||
for (key, value) in entries {
|
||||
// `null` puts a switch back to its default, on.
|
||||
let parsed = match value {
|
||||
Value::Null => Ok(LegacyProtocols::Enabled),
|
||||
value => parse_switch(value.as_str().as_deref()),
|
||||
};
|
||||
let result = match &key {
|
||||
Key::Property(P::LegacyProtocols) => match value {
|
||||
Value::Null => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
|
||||
policy.set_all(value);
|
||||
if !value.is_disabled() {
|
||||
turned_on.extend(SWITCHED.iter().copied());
|
||||
} else {
|
||||
turned_on.clear();
|
||||
}
|
||||
value => match value.as_str().as_deref() {
|
||||
Some("enabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
}),
|
||||
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
|
||||
parsed.map(|value| {
|
||||
let name = switch_name(property).unwrap_or_default();
|
||||
policy.set(name, value);
|
||||
turned_on.retain(|p| *p != name);
|
||||
if !value.is_disabled() {
|
||||
turned_on.push(name);
|
||||
}
|
||||
Some("disabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Disabled;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(r#"must be "enabled" or "disabled""#),
|
||||
},
|
||||
},
|
||||
Key::Property(P::Id) => Err("is immutable"),
|
||||
Key::Property(_) => Err("is set by the server"),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
||||
})
|
||||
}
|
||||
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||
Key::Property(_) => Err("is set by the server".to_string()),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
|
||||
};
|
||||
if let Err(why) = result {
|
||||
error = Some(
|
||||
@@ -203,15 +234,18 @@ pub async fn set(
|
||||
continue;
|
||||
}
|
||||
|
||||
// LP-9: server off means off for everyone.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
||||
// LP-9: server off means off for everyone, protocol by protocol.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
continue;
|
||||
}
|
||||
|
||||
if policy.legacy_protocols != previous.legacy_protocols {
|
||||
policy.normalize();
|
||||
let mut before = previous;
|
||||
before.normalize();
|
||||
if policy.off() != before.off() {
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||
@@ -221,11 +255,7 @@ pub async fn set(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "tenant",
|
||||
Id = tenant_id,
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user