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:
2026-09-21 11:18:42 -07:00
parent 64cddc9246
commit b65afb66f9
24 changed files with 1001 additions and 53 deletions
+14 -1
View File
@@ -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(),
},
+17
View File
@@ -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)?;
+1
View File
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(),
})
}
+1
View File
@@ -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)
}