Legacy mail apps: tenant switch, session flag, use panel and cleanup #10
@@ -33,6 +33,7 @@
|
||||
use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor};
|
||||
use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
legacy_use::{self, LegacyUse},
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
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 --
|
||||
/// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session;
|
||||
/// 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).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefusalScope {
|
||||
@@ -386,11 +408,16 @@ impl Server {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The same, once the account is known (LP-10). A bearer token needn't
|
||||
/// name an account, so a sign-in with one can't be judged by its domain
|
||||
/// beforehand; this judges it by the tenant the token turned out to
|
||||
/// belong to. For a password sign-in it has already been decided.
|
||||
pub async fn refuse_legacy_session(
|
||||
/// Once the account is known: refuses it if its tenant has legacy
|
||||
/// protocols off, and otherwise records the sign-in for the impact panel.
|
||||
///
|
||||
/// The refusal is LP-10 again for a bearer token, which needn't name an
|
||||
/// 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,
|
||||
protocol: LegacyProtocol,
|
||||
access_token: &AccessToken,
|
||||
@@ -400,9 +427,42 @@ impl Server {
|
||||
{
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// server's switch and its tenant's. What the JMAP session tells the
|
||||
/// 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
|
||||
//! `legacy-protocols.md`.
|
||||
|
||||
pub mod legacy_use;
|
||||
pub mod listeners;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
|
||||
@@ -100,9 +100,10 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?;
|
||||
|
||||
// inbuxa: legacy-protocols LP-10, for a bearer token that named no account
|
||||
// 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
|
||||
.refuse_legacy_session(LegacyProtocol::Imap, &access_token)
|
||||
.admit_legacy_session(LegacyProtocol::Imap, &access_token)
|
||||
.await
|
||||
.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,
|
||||
/// by name and port, for the confirmation (LP-16).
|
||||
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)]
|
||||
@@ -57,6 +60,7 @@ impl Property for ProtocolPolicyProperty {
|
||||
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
ProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||
ProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
|
||||
ProtocolPolicyProperty::WouldClose => "wouldClose",
|
||||
}
|
||||
@@ -73,6 +77,7 @@ impl ProtocolPolicyProperty {
|
||||
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||
b"changedBy" => ProtocolPolicyProperty::ChangedBy,
|
||||
b"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse,
|
||||
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
|
||||
b"wouldClose" => ProtocolPolicyProperty::WouldClose,
|
||||
)
|
||||
@@ -88,6 +93,7 @@ impl ProtocolPolicyProperty {
|
||||
ProtocolPolicyProperty::SavedListeners
|
||||
| ProtocolPolicyProperty::ChangedAt
|
||||
| ProtocolPolicyProperty::ChangedBy
|
||||
| ProtocolPolicyProperty::RecentLegacyUse
|
||||
| ProtocolPolicyProperty::LockedProtocols
|
||||
| ProtocolPolicyProperty::WouldClose
|
||||
)
|
||||
|
||||
@@ -28,6 +28,9 @@ pub enum TenantProtocolPolicyProperty {
|
||||
LegacyProtocols,
|
||||
ChangedAt,
|
||||
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)]
|
||||
@@ -47,6 +50,7 @@ impl Property for TenantProtocolPolicyProperty {
|
||||
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -60,6 +64,7 @@ impl TenantProtocolPolicyProperty {
|
||||
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +78,7 @@ impl TenantProtocolPolicyProperty {
|
||||
TenantProtocolPolicyProperty::TenantId
|
||||
| TenantProtocolPolicyProperty::ChangedAt
|
||||
| TenantProtocolPolicyProperty::ChangedBy
|
||||
| TenantProtocolPolicyProperty::RecentLegacyUse
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
//! (LP-4).
|
||||
|
||||
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::{
|
||||
listeners,
|
||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||
@@ -50,6 +54,7 @@ const ALL: &[P] = &[
|
||||
P::ChangedBy,
|
||||
P::LockedProtocols,
|
||||
P::WouldClose,
|
||||
P::RecentLegacyUse,
|
||||
];
|
||||
|
||||
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
@@ -86,7 +91,12 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
||||
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());
|
||||
for property in properties {
|
||||
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
|
||||
// (LP-16).
|
||||
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);
|
||||
}
|
||||
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.
|
||||
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||
let mut hypothetical = policy.clone();
|
||||
@@ -164,17 +207,22 @@ pub async fn get(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let recent = if properties.contains(&P::RecentLegacyUse) {
|
||||
server.recent_legacy_use(None).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
match ids {
|
||||
None => response
|
||||
.list
|
||||
.push(to_value(&policy, &would_close, &properties)),
|
||||
.push(to_value(&policy, &would_close, &recent, &properties)),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
response
|
||||
.list
|
||||
.push(to_value(&policy, &would_close, &properties));
|
||||
.push(to_value(&policy, &would_close, &recent, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
//! 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 crate::inbuxa::protocol_policy::recent_value;
|
||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||
use inbuxa_features::{
|
||||
security::{
|
||||
protocol_policy::LegacyProtocols,
|
||||
@@ -47,6 +48,7 @@ const ALL: &[P] = &[
|
||||
P::LegacyProtocols,
|
||||
P::ChangedAt,
|
||||
P::ChangedBy,
|
||||
P::RecentLegacyUse,
|
||||
];
|
||||
|
||||
/// 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());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
@@ -81,6 +83,9 @@ fn to_value(tenant_id: u32, policy: &Policy, properties: &[P]) -> PValue {
|
||||
.as_ref()
|
||||
.map(|by| Value::Str(by.clone().into()))
|
||||
.unwrap_or(Value::Null),
|
||||
P::RecentLegacyUse => {
|
||||
recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id)))
|
||||
}
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
@@ -111,9 +116,15 @@ pub async fn get(
|
||||
let tenant_id = id.document_id();
|
||||
if reachable.contains(&tenant_id) {
|
||||
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
|
||||
.list
|
||||
.push(to_value(tenant_id, &policy, &properties));
|
||||
.push(to_value(tenant_id, &policy, &recent, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
|
||||
@@ -101,9 +101,10 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?;
|
||||
|
||||
// inbuxa: legacy-protocols LP-10, for a bearer token that named no account
|
||||
// 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
|
||||
.refuse_legacy_session(LegacyProtocol::ManageSieve, &access_token)
|
||||
.admit_legacy_session(LegacyProtocol::ManageSieve, &access_token)
|
||||
.await?;
|
||||
|
||||
// Enforce concurrency limits
|
||||
|
||||
@@ -99,9 +99,10 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?;
|
||||
|
||||
// inbuxa: legacy-protocols LP-10, for a bearer token that named no account
|
||||
// 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
|
||||
.refuse_legacy_session(LegacyProtocol::Pop3, &access_token)
|
||||
.admit_legacy_session(LegacyProtocol::Pop3, &access_token)
|
||||
.await?;
|
||||
|
||||
// Enforce concurrency limits
|
||||
|
||||
@@ -136,11 +136,12 @@ impl<T: SessionStream> Session<T> {
|
||||
.and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend));
|
||||
|
||||
// inbuxa: legacy-protocols LP-10, for a bearer token that named no
|
||||
// account and so couldn't be judged by its domain beforehand.
|
||||
// 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
|
||||
&& let Err(err) = self
|
||||
.server
|
||||
.refuse_legacy_session(LegacyProtocol::Submission, access_token)
|
||||
.admit_legacy_session(LegacyProtocol::Submission, access_token)
|
||||
.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,
|
||||
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
|
||||
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.
|
||||
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",
|
||||
"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.
|
||||
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"),
|
||||
"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).
|
||||
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||
if got[0] != "inbuxa:ProtocolPolicy/get":
|
||||
|
||||
Reference in New Issue
Block a user