Author SHA1 Message Date
jcoffey-dev 840215d109 Merge branch 'feat/session-legacy-flag' into feat/legacy-use-panel 2026-09-21 13:48:35 -07:00
jcoffey-dev 3f40b36032 The switch knows who still uses legacy mail apps (LP-15, server)
The impact panel's data. Every successful sign-in over IMAP, POP3,
ManageSieve or SMTP AUTH records, per account and per protocol, one
timestamp -- nothing else: no address, no IP, no client. It is written at
most once an hour per account and protocol, so a mail app polling every
minute costs a read per sign-in and a write an hour. A record that can't be
written is logged and the sign-in goes ahead.

Both switches serve it as a read-only property, recentLegacyUse, as
wouldClose serves the confirmation: a list of {accountId, name, protocol,
lastUsedAt} for sign-ins in the last 30 days, most recent first.
inbuxa:ProtocolPolicy lists every account; inbuxa:TenantProtocolPolicy
lists only its tenant's own (MT-1). Accounts since deleted are left out. It
is computed only when the property is asked for.

The recording sits where the tenant check already runs once the account is
known, which becomes admit_legacy_session: refuse if the account's tenant
has legacy protocols off, otherwise record. A refused sign-in is never
recorded.

The spec leaves the interface to the implementation; a property on each
switch keeps the panel's data behind the same permission as the switch
itself, with no new object.

Unit tests hold the 30-day window to acceptance test 11 (three days ago
listed, forty not), the hourly throttle and the keys. The e2e proves on a
running server that the admin's IMAP and submission sign-ins are listed
with their time, that a second sign-in within the hour isn't written again,
and that a tenant's list holds its own user and nobody outside the tenant.
All 70 checks pass.
2026-09-21 11:45:05 -07:00
12 changed files with 403 additions and 21 deletions
+65 -5
View File
@@ -33,6 +33,7 @@
use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor}; use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor};
use directory::Credentials; use directory::Credentials;
use inbuxa_features::security::{ use inbuxa_features::security::{
legacy_use::{self, LegacyUse},
listeners, listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener}, protocol_policy::{self, ProtocolPolicy, SavedListener},
tenant_protocol_policy, tenant_protocol_policy,
@@ -280,6 +281,16 @@ impl LegacyProtocol {
} }
} }
/// The same protocol, as the impact panel's record names it (LP-15).
pub fn as_use(&self) -> LegacyUse {
match self {
LegacyProtocol::Imap => LegacyUse::Imap,
LegacyProtocol::Pop3 => LegacyUse::Pop3,
LegacyProtocol::ManageSieve => LegacyUse::ManageSieve,
LegacyProtocol::Submission => LegacyUse::Submission,
}
}
/// What the mail app is told (LP-12). Each protocol's own framing -- /// What the mail app is told (LP-12). Each protocol's own framing --
/// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session; /// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session;
/// POP3 carries `[AUTH]` in the text, since its errors have no separate /// POP3 carries `[AUTH]` in the text, since its errors have no separate
@@ -338,6 +349,17 @@ impl LegacyProtocol {
} }
} }
/// One account's last sign-in over one legacy protocol, as the impact panel
/// shows it (LP-15).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentUse {
pub account_id: u32,
pub name: String,
pub protocol: &'static str,
/// Seconds since the epoch.
pub at: u64,
}
/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10). /// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefusalScope { pub enum RefusalScope {
@@ -386,11 +408,16 @@ impl Server {
Ok(()) Ok(())
} }
/// The same, once the account is known (LP-10). A bearer token needn't /// Once the account is known: refuses it if its tenant has legacy
/// name an account, so a sign-in with one can't be judged by its domain /// protocols off, and otherwise records the sign-in for the impact panel.
/// beforehand; this judges it by the tenant the token turned out to ///
/// belong to. For a password sign-in it has already been decided. /// The refusal is LP-10 again for a bearer token, which needn't name an
pub async fn refuse_legacy_session( /// account and so can't be judged by its domain beforehand; for a
/// password sign-in it has already been decided. The record is LP-15's:
/// one timestamp per account and protocol, at most hourly. A record that
/// can't be written is logged and the sign-in goes ahead -- a panel is
/// not worth locking anyone out over.
pub async fn admit_legacy_session(
&self, &self,
protocol: LegacyProtocol, protocol: LegacyProtocol,
access_token: &AccessToken, access_token: &AccessToken,
@@ -400,9 +427,42 @@ impl Server {
{ {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None)); return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
} }
if let Err(err) = legacy_use::record(
&self.core.storage.data,
access_token.account_id(),
protocol.as_use(),
store::write::now(),
)
.await
{
trc::error!(err.details("Failed to record a legacy sign-in (LP-15)."));
}
Ok(()) Ok(())
} }
/// Who signed in over a legacy protocol in the last 30 days, most recent
/// first, for the impact panel (LP-15): everyone at server scope, or one
/// tenant's accounts. Accounts that no longer exist are left out.
pub async fn recent_legacy_use(&self, tenant_id: Option<u32>) -> trc::Result<Vec<RecentUse>> {
let mut recent = Vec::new();
for entry in legacy_use::recent(&self.core.storage.data, store::write::now()).await? {
let Some(account) = self.try_account(entry.account_id).await? else {
continue;
};
if tenant_id.is_some() && account.id_tenant != tenant_id {
continue;
}
recent.push(RecentUse {
account_id: entry.account_id,
name: account.name.to_string(),
protocol: entry.protocol.as_str(),
at: entry.at,
});
}
recent.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.name.cmp(&b.name)));
Ok(recent)
}
/// Whether legacy protocols are off for this account: the stricter of the /// Whether legacy protocols are off for this account: the stricter of the
/// server's switch and its tenant's. What the JMAP session tells the /// server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can /// account's apps (legacy-protocols spec, Interfaces), so the webmail can
+220
View File
@@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! When each account last signed in over each legacy protocol, for the
//! impact panel (legacy-protocols spec, LP-15, "Last use per protocol").
//!
//! One timestamp per account per protocol, and nothing else: no address, no
//! IP, no client. It is written at most once an hour per account and
//! protocol, so a mail app polling every minute costs one read per sign-in
//! and one write an hour. Stored under `P` `u`, the account id and a protocol
//! byte, in the fork's subspace.
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// How long a recorded use stands before the next sign-in rewrites it.
pub const WRITE_EVERY_SECS: u64 = 3600;
/// How far back the impact panel looks (LP-15).
pub const RECENT_SECS: u64 = 30 * 24 * 3600;
/// The protocols the panel names, as they are spelled over JMAP.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LegacyUse {
Imap,
Pop3,
ManageSieve,
Submission,
}
impl LegacyUse {
pub fn as_str(&self) -> &'static str {
match self {
LegacyUse::Imap => "imap",
LegacyUse::Pop3 => "pop3",
LegacyUse::ManageSieve => "manageSieve",
LegacyUse::Submission => "submission",
}
}
fn byte(&self) -> u8 {
match self {
LegacyUse::Imap => b'i',
LegacyUse::Pop3 => b'p',
LegacyUse::ManageSieve => b's',
LegacyUse::Submission => b'm',
}
}
fn from_byte(byte: u8) -> Option<Self> {
match byte {
b'i' => Some(LegacyUse::Imap),
b'p' => Some(LegacyUse::Pop3),
b's' => Some(LegacyUse::ManageSieve),
b'm' => Some(LegacyUse::Submission),
_ => None,
}
}
}
/// One account's last use of one protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Use {
pub account_id: u32,
pub protocol: LegacyUse,
/// Seconds since the epoch.
pub at: u64,
}
fn key(account_id: u32, protocol: Option<LegacyUse>) -> ValueKey<ValueClass> {
let mut key = Vec::with_capacity(7);
key.extend_from_slice(b"Pu");
key.extend_from_slice(&account_id.to_be_bytes());
key.push(protocol.map_or(0, |p| p.byte()));
ValueKey::from(ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
}))
}
/// Reads a stored key back into who and what, if it is one of ours.
fn parse_key(key: &[u8]) -> Option<(u32, LegacyUse)> {
// The iterator may or may not hand back the subspace byte; the tail is
// what identifies an entry: two bytes of prefix, four of account id and
// one of protocol.
let tail = key.get(key.len().checked_sub(7)?..)?;
(tail[..2] == *b"Pu").then_some(())?;
let account_id = u32::from_be_bytes(tail[2..6].try_into().ok()?);
Some((account_id, LegacyUse::from_byte(tail[6])?))
}
struct At(u64);
impl Deserialize for At {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
bytes
.try_into()
.map(|bytes| At(u64::from_be_bytes(bytes)))
.map_err(|_| trc::StoreEvent::DataCorruption.caused_by(trc::location!()))
}
}
/// Whether a use at `at` is recent enough for the panel at `now` (LP-15).
pub fn is_recent(at: u64, now: u64) -> bool {
at >= now.saturating_sub(RECENT_SECS)
}
/// Whether a use at `now` should be written over one stored at `stored`.
fn due(stored: Option<u64>, now: u64) -> bool {
stored.is_none_or(|stored| now.saturating_sub(stored) >= WRITE_EVERY_SECS)
}
/// Records a successful sign-in, unless one was recorded within the hour.
pub async fn record(
data: &Store,
account_id: u32,
protocol: LegacyUse,
now: u64,
) -> trc::Result<()> {
let stored = data
.get_value::<At>(key(account_id, Some(protocol)))
.await
.caused_by(trc::location!())?
.map(|At(at)| at);
if !due(stored, now) {
return Ok(());
}
let mut batch = BatchBuilder::new();
batch.set(
key(account_id, Some(protocol)).class,
now.to_be_bytes().to_vec(),
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// Every use recent at `now` (LP-15), across all accounts.
pub async fn recent(data: &Store, now: u64) -> trc::Result<Vec<Use>> {
let mut uses = Vec::new();
data.iterate(
IterateParams::new(key(0, None), key(u32::MAX, Some(LegacyUse::Submission))).ascending(),
|key, value| {
if let Some((account_id, protocol)) = parse_key(key)
&& let Ok(At(at)) = At::deserialize(value)
&& is_recent(at, now)
{
uses.push(Use {
account_id,
protocol,
at,
});
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok(uses)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn written_at_most_once_an_hour() {
assert!(due(None, 100));
assert!(!due(Some(100), 100 + WRITE_EVERY_SECS - 1));
assert!(due(Some(100), 100 + WRITE_EVERY_SECS));
// A clock that went backwards doesn't write.
assert!(!due(Some(100), 50));
}
#[test]
fn the_panel_looks_back_thirty_days() {
// Acceptance test 11: three days ago is listed, forty days ago isn't.
let now = 1_800_000_000;
let day = 24 * 3600;
assert!(is_recent(now - 3 * day, now));
assert!(is_recent(now - 30 * day, now));
assert!(!is_recent(now - 30 * day - 1, now));
assert!(!is_recent(now - 40 * day, now));
}
#[test]
fn keys_read_back() {
for protocol in [
LegacyUse::Imap,
LegacyUse::Pop3,
LegacyUse::ManageSieve,
LegacyUse::Submission,
] {
let ValueClass::Any(any) = key(42, Some(protocol)).class else {
panic!()
};
assert_eq!(parse_key(&any.key), Some((42, protocol)));
// With the subspace byte in front, too.
let mut with_subspace = vec![SUBSPACE_INBUXA];
with_subspace.extend_from_slice(&any.key);
assert_eq!(parse_key(&with_subspace), Some((42, protocol)));
}
assert_eq!(parse_key(b"Pp"), None);
assert_eq!(parse_key(b"Xx\0\0\0\x2ai"), None);
}
#[test]
fn only_protocols_are_recorded() {
// Nothing but the four legacy protocols has a byte of its own.
assert_eq!(LegacyUse::from_byte(0), None);
assert_eq!(LegacyUse::from_byte(b'x'), None);
}
}
+1
View File
@@ -10,6 +10,7 @@
//! ships. The legacy-protocols switch is INBUXA's own design, specified in //! ships. The legacy-protocols switch is INBUXA's own design, specified in
//! `legacy-protocols.md`. //! `legacy-protocols.md`.
pub mod legacy_use;
pub mod listeners; pub mod listeners;
pub mod protocol_policy; pub mod protocol_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
+3 -2
View File
@@ -100,9 +100,10 @@ impl<T: SessionStream> Session<T> {
}) })
.and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?;
// inbuxa: legacy-protocols LP-10, for a bearer token that named no account // inbuxa: legacy-protocols LP-10 for a bearer token that named no
// account, and LP-15: the sign-in is recorded for the impact panel
self.server self.server
.refuse_legacy_session(LegacyProtocol::Imap, &access_token) .admit_legacy_session(LegacyProtocol::Imap, &access_token)
.await .await
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?; .map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
@@ -37,6 +37,9 @@ pub enum ProtocolPolicyProperty {
/// Server-set: exactly which listeners turning the switch would close, /// Server-set: exactly which listeners turning the switch would close,
/// by name and port, for the confirmation (LP-16). /// by name and port, for the confirmation (LP-16).
WouldClose, WouldClose,
/// Server-set: who signed in over a legacy protocol in the last 30
/// days, and when, for the impact panel (LP-15).
RecentLegacyUse,
} }
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -57,6 +60,7 @@ impl Property for ProtocolPolicyProperty {
ProtocolPolicyProperty::SavedListeners => "savedListeners", ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt", ProtocolPolicyProperty::ChangedAt => "changedAt",
ProtocolPolicyProperty::ChangedBy => "changedBy", ProtocolPolicyProperty::ChangedBy => "changedBy",
ProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols", ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
ProtocolPolicyProperty::WouldClose => "wouldClose", ProtocolPolicyProperty::WouldClose => "wouldClose",
} }
@@ -73,6 +77,7 @@ impl ProtocolPolicyProperty {
b"savedListeners" => ProtocolPolicyProperty::SavedListeners, b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt, b"changedAt" => ProtocolPolicyProperty::ChangedAt,
b"changedBy" => ProtocolPolicyProperty::ChangedBy, b"changedBy" => ProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse,
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols, b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
b"wouldClose" => ProtocolPolicyProperty::WouldClose, b"wouldClose" => ProtocolPolicyProperty::WouldClose,
) )
@@ -88,6 +93,7 @@ impl ProtocolPolicyProperty {
ProtocolPolicyProperty::SavedListeners ProtocolPolicyProperty::SavedListeners
| ProtocolPolicyProperty::ChangedAt | ProtocolPolicyProperty::ChangedAt
| ProtocolPolicyProperty::ChangedBy | ProtocolPolicyProperty::ChangedBy
| ProtocolPolicyProperty::RecentLegacyUse
| ProtocolPolicyProperty::LockedProtocols | ProtocolPolicyProperty::LockedProtocols
| ProtocolPolicyProperty::WouldClose | ProtocolPolicyProperty::WouldClose
) )
@@ -28,6 +28,9 @@ pub enum TenantProtocolPolicyProperty {
LegacyProtocols, LegacyProtocols,
ChangedAt, ChangedAt,
ChangedBy, ChangedBy,
/// Server-set: who signed in over a legacy protocol in the last 30
/// days, and when, for the impact panel (LP-15).
RecentLegacyUse,
} }
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -47,6 +50,7 @@ impl Property for TenantProtocolPolicyProperty {
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
TenantProtocolPolicyProperty::ChangedAt => "changedAt", TenantProtocolPolicyProperty::ChangedAt => "changedAt",
TenantProtocolPolicyProperty::ChangedBy => "changedBy", TenantProtocolPolicyProperty::ChangedBy => "changedBy",
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
} }
.into() .into()
} }
@@ -60,6 +64,7 @@ impl TenantProtocolPolicyProperty {
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols, b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt, b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy, b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
) )
} }
} }
@@ -73,6 +78,7 @@ impl TenantProtocolPolicyProperty {
TenantProtocolPolicyProperty::TenantId TenantProtocolPolicyProperty::TenantId
| TenantProtocolPolicyProperty::ChangedAt | TenantProtocolPolicyProperty::ChangedAt
| TenantProtocolPolicyProperty::ChangedBy | TenantProtocolPolicyProperty::ChangedBy
| TenantProtocolPolicyProperty::RecentLegacyUse
) )
} }
} }
+52 -4
View File
@@ -19,7 +19,11 @@
//! (LP-4). //! (LP-4).
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult}; use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
use common::{Server, auth::AccessToken, network::legacy::PolicyChange}; use common::{
Server,
auth::AccessToken,
network::legacy::{PolicyChange, RecentUse},
};
use inbuxa_features::security::{ use inbuxa_features::security::{
listeners, listeners,
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener}, protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
@@ -50,6 +54,7 @@ const ALL: &[P] = &[
P::ChangedBy, P::ChangedBy,
P::LockedProtocols, P::LockedProtocols,
P::WouldClose, P::WouldClose,
P::RecentLegacyUse,
]; ];
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> { fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
@@ -86,7 +91,12 @@ fn listener_value(listener: &SavedListener) -> PValue {
Value::Object(out) Value::Object(out)
} }
fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> PValue { fn to_value(
policy: &Policy,
would_close: &[SavedListener],
recent: &[RecentUse],
properties: &[P],
) -> PValue {
let mut out = Map::with_capacity(properties.len()); let mut out = Map::with_capacity(properties.len());
for property in properties { for property in properties {
let value = match property { let value = match property {
@@ -127,12 +137,45 @@ fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) ->
// port, so the confirmation can say so before anything happens // port, so the confirmation can say so before anything happens
// (LP-16). // (LP-16).
P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()), P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()),
// Who would notice, before anything changes (LP-15).
P::RecentLegacyUse => recent_value(recent, |id| ProtocolPolicyValue::Id(Id::from(id))),
}; };
out.insert_unchecked(Key::Property(property.clone()), value); out.insert_unchecked(Key::Property(property.clone()), value);
} }
Value::Object(out) Value::Object(out)
} }
/// The impact panel's list (LP-15): who, over what, and when, in
/// milliseconds as `changedAt` is. Shared with the tenant's switch.
pub(crate) fn recent_value<Pr, V>(
recent: &[RecentUse],
id: impl Fn(u32) -> V,
) -> Value<'static, Pr, V>
where
Pr: jmap_tools::Property,
V: jmap_tools::Element<Property = Pr>,
{
Value::Array(
recent
.iter()
.map(|entry| {
let mut out = Map::with_capacity(4);
out.insert_unchecked(
Key::Borrowed("accountId"),
Value::Element(id(entry.account_id)),
);
out.insert_unchecked(Key::Borrowed("name"), Value::Str(entry.name.clone().into()));
out.insert_unchecked(Key::Borrowed("protocol"), Value::Str(entry.protocol.into()));
out.insert_unchecked(
Key::Borrowed("lastUsedAt"),
Value::Number((entry.at * 1000).into()),
);
Value::Object(out)
})
.collect(),
)
}
/// The listeners turning the switch on would close, whatever it is now. /// The listeners turning the switch on would close, whatever it is now.
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> { async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
let mut hypothetical = policy.clone(); let mut hypothetical = policy.clone();
@@ -164,17 +207,22 @@ pub async fn get(
} else { } else {
Vec::new() Vec::new()
}; };
let recent = if properties.contains(&P::RecentLegacyUse) {
server.recent_legacy_use(None).await?
} else {
Vec::new()
};
match ids { match ids {
None => response None => response
.list .list
.push(to_value(&policy, &would_close, &properties)), .push(to_value(&policy, &would_close, &recent, &properties)),
Some(ids) => { Some(ids) => {
for id in ids { for id in ids {
if id.is_singleton() { if id.is_singleton() {
response response
.list .list
.push(to_value(&policy, &would_close, &properties)); .push(to_value(&policy, &would_close, &recent, &properties));
} else { } else {
response.push_not_found(id); response.push_not_found(id);
} }
@@ -17,7 +17,8 @@
//! A tenant's switch closes no port (LP-13) -- sign-in and client //! A tenant's switch closes no port (LP-13) -- sign-in and client
//! configuration read it (LP-10, LP-14a). //! configuration read it (LP-10, LP-14a).
use common::{Server, auth::AccessToken}; use crate::inbuxa::protocol_policy::recent_value;
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
use inbuxa_features::{ use inbuxa_features::{
security::{ security::{
protocol_policy::LegacyProtocols, protocol_policy::LegacyProtocols,
@@ -47,6 +48,7 @@ const ALL: &[P] = &[
P::LegacyProtocols, P::LegacyProtocols,
P::ChangedAt, P::ChangedAt,
P::ChangedBy, P::ChangedBy,
P::RecentLegacyUse,
]; ];
/// The tenants this principal may reach: its own inside a tenant (MT-1), /// The tenants this principal may reach: its own inside a tenant (MT-1),
@@ -58,7 +60,7 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
} }
} }
fn to_value(tenant_id: u32, policy: &Policy, properties: &[P]) -> PValue { fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
let mut out = Map::with_capacity(properties.len()); let mut out = Map::with_capacity(properties.len());
for property in properties { for property in properties {
let value = match property { let value = match property {
@@ -81,6 +83,9 @@ fn to_value(tenant_id: u32, policy: &Policy, properties: &[P]) -> PValue {
.as_ref() .as_ref()
.map(|by| Value::Str(by.clone().into())) .map(|by| Value::Str(by.clone().into()))
.unwrap_or(Value::Null), .unwrap_or(Value::Null),
P::RecentLegacyUse => {
recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id)))
}
}; };
out.insert_unchecked(Key::Property(property.clone()), value); out.insert_unchecked(Key::Property(property.clone()), value);
} }
@@ -111,9 +116,15 @@ pub async fn get(
let tenant_id = id.document_id(); let tenant_id = id.document_id();
if reachable.contains(&tenant_id) { if reachable.contains(&tenant_id) {
let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?; let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?;
// The tenant's own people only (LP-15, MT-1).
let recent = if properties.contains(&P::RecentLegacyUse) {
server.recent_legacy_use(Some(tenant_id)).await?
} else {
Vec::new()
};
response response
.list .list
.push(to_value(tenant_id, &policy, &properties)); .push(to_value(tenant_id, &policy, &recent, &properties));
} else { } else {
response.push_not_found(id); response.push_not_found(id);
} }
+3 -2
View File
@@ -101,9 +101,10 @@ impl<T: SessionStream> Session<T> {
}) })
.and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?; .and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?;
// inbuxa: legacy-protocols LP-10, for a bearer token that named no account // inbuxa: legacy-protocols LP-10 for a bearer token that named no
// account, and LP-15: the sign-in is recorded for the impact panel
self.server self.server
.refuse_legacy_session(LegacyProtocol::ManageSieve, &access_token) .admit_legacy_session(LegacyProtocol::ManageSieve, &access_token)
.await?; .await?;
// Enforce concurrency limits // Enforce concurrency limits
+3 -2
View File
@@ -99,9 +99,10 @@ impl<T: SessionStream> Session<T> {
}) })
.and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?; .and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?;
// inbuxa: legacy-protocols LP-10, for a bearer token that named no account // inbuxa: legacy-protocols LP-10 for a bearer token that named no
// account, and LP-15: the sign-in is recorded for the impact panel
self.server self.server
.refuse_legacy_session(LegacyProtocol::Pop3, &access_token) .admit_legacy_session(LegacyProtocol::Pop3, &access_token)
.await?; .await?;
// Enforce concurrency limits // Enforce concurrency limits
+3 -2
View File
@@ -136,11 +136,12 @@ impl<T: SessionStream> Session<T> {
.and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend)); .and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend));
// inbuxa: legacy-protocols LP-10, for a bearer token that named no // inbuxa: legacy-protocols LP-10, for a bearer token that named no
// account and so couldn't be judged by its domain beforehand. // account and so couldn't be judged by its domain beforehand; and
// LP-15, the sign-in is recorded for the impact panel.
if let Ok(access_token) = &result if let Ok(access_token) = &result
&& let Err(err) = self && let Err(err) = self
.server .server
.refuse_legacy_session(LegacyProtocol::Submission, access_token) .admit_legacy_session(LegacyProtocol::Submission, access_token)
.await .await
{ {
return self.legacy_refusal(err).await; return self.legacy_refusal(err).await;
+27 -1
View File
@@ -30,7 +30,9 @@ leaves every other domain alone, and stops client configuration offering
legacy servers for those domains. It reaches only its own tenant's switch, 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 and can't turn it back on while the server has legacy protocols off
(acceptance tests 6 to 10, 14). Throughout, the JMAP session tells each (acceptance tests 6 to 10, 14). Throughout, the JMAP session tells each
account which way its switches point (test 13). account which way its switches point (test 13), and the impact panel's
list names who signed in over what: every account at server scope, only the
tenant's own at tenant scope, rewritten at most once an hour (LP-15).
Passwords are generated into files under target/e2e and never printed. Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1. Everything is removed afterwards unless KEEP=1.
@@ -292,6 +294,14 @@ def tenant_checks(admin, admin_pw, account):
check(session_flag(tu, user_pw) == "enabled", check(session_flag(tu, user_pw) == "enabled",
"the session says enabled for the tenant's user while both switches are on (test 13)") "the session says enabled for the tenant's user while both switches are on (test 13)")
imap_login(PORTS["imap"], tu, user_pw)
got = tget()
recent = got[1]["list"][0].get("recentLegacyUse", [])
names = {(r["name"], r["protocol"]) for r in recent}
check((tu, "imap") in names and not any(n == admin for n, _ in names),
"the tenant's panel lists its own user's IMAP sign-in and nobody outside it (LP-15, MT-1)")
if (tu, "imap") not in names:
print(" recent:", recent)
# Before: the tenant's user signs in, and its domain is offered IMAP. # Before: the tenant's user signs in, and its domain is offered IMAP.
check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"),
@@ -460,6 +470,22 @@ def main():
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"), check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
"submission sign-in works with the switch on") "submission sign-in works with the switch on")
# The impact panel (LP-15): the sign-ins above are on it, once each.
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
{"accountId": account, "ids": None, "properties": ["recentLegacyUse"]})
recent = got[1]["list"][0].get("recentLegacyUse", [])
mine = {r["protocol"]: r for r in recent if r["name"] == admin}
check(set(mine) == {"imap", "submission"} and all(r["lastUsedAt"] > 0 for r in mine.values()),
"the panel lists the admin's IMAP and submission sign-ins, and when (LP-15)")
if set(mine) != {"imap", "submission"}:
print(" recent:", recent)
imap_login(PORTS["imap"], admin, admin_pw)
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
{"accountId": account, "ids": None, "properties": ["recentLegacyUse"]})
again = {r["protocol"]: r for r in got[1]["list"][0].get("recentLegacyUse", []) if r["name"] == admin}
check(again.get("imap", {}).get("lastUsedAt") == mine.get("imap", {}).get("lastUsedAt"),
"a second sign-in within the hour isn't written again (LP-15)")
# What the screen reads: the locked set and what would close (LP-16, LP-21). # What the screen reads: the locked set and what would close (LP-16, LP-21).
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get) got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
if got[0] != "inbuxa:ProtocolPolicy/get": if got[0] != "inbuxa:ProtocolPolicy/get":