Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c6fe91d0c | ||
|
|
840215d109 | ||
|
|
3f40b36032 |
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) ->
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forgets a tenant's switch, when the tenant is deleted. Otherwise a tenant
|
||||||
|
/// that came to have the same id would start with the old one's switch.
|
||||||
|
pub async fn remove(data: &Store, tenant_id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(key(tenant_id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -851,6 +851,14 @@ impl RegistrySet for Server {
|
|||||||
if let ObjectInner::MaskedEmail(mask) = &object.inner {
|
if let ObjectInner::MaskedEmail(mask) = &object.inner {
|
||||||
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
|
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
|
||||||
}
|
}
|
||||||
|
// inbuxa: legacy-protocols, a tenant's switch goes with it
|
||||||
|
if matches!(object.inner, ObjectInner::Tenant(_)) {
|
||||||
|
inbuxa_features::security::tenant_protocol_policy::remove(
|
||||||
|
&self.core.storage.data,
|
||||||
|
id.document_id(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
cache_invalidator.process_delete(id, &object);
|
cache_invalidator.process_delete(id, &object);
|
||||||
set.response.destroyed.push(id);
|
set.response.destroyed.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"),
|
||||||
@@ -370,6 +380,24 @@ def tenant_checks(admin, admin_pw, account):
|
|||||||
"and its user signs in over IMAP again")
|
"and its user signs in over IMAP again")
|
||||||
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
|
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
|
||||||
|
|
||||||
|
# A deleted tenant's switch goes with it, so a tenant that later gets the
|
||||||
|
# same id doesn't start with legacy protocols off.
|
||||||
|
sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get",
|
||||||
|
{"accountId": account, "ids": ids})
|
||||||
|
one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/set",
|
||||||
|
{"accountId": account, "update": {t2: {"legacyProtocols": "disabled"}}})
|
||||||
|
check(sget([t2])[1]["list"][0]["legacyProtocols"] == "disabled",
|
||||||
|
"a server admin turns another tenant's switch off")
|
||||||
|
res = one(admin, admin_pw, "x:Tenant/set", {"destroy": [t2]})
|
||||||
|
check(t2 in (res[1].get("destroyed") or []), "that tenant can be deleted")
|
||||||
|
t3 = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t3"}}}),
|
||||||
|
"t", "third tenant")
|
||||||
|
if t3 == t2:
|
||||||
|
check(sget([t3])[1]["list"][0]["legacyProtocols"] == "enabled",
|
||||||
|
"a new tenant with the deleted one's id starts with legacy protocols on")
|
||||||
|
else:
|
||||||
|
print(f" (the registry gave the new tenant a fresh id, {t3} not {t2}: reuse not observable)")
|
||||||
|
|
||||||
|
|
||||||
def session_flag(user, password):
|
def session_flag(user, password):
|
||||||
"""legacyProtocols from the account's urn:inbuxa:jmap capability."""
|
"""legacyProtocols from the account's urn:inbuxa:jmap capability."""
|
||||||
@@ -460,6 +488,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":
|
||||||
|
|||||||
Reference in New Issue
Block a user